diff --git a/packages/ltx-trainer/configs/a2v_lora.yaml b/packages/ltx-trainer/configs/a2v_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..45c91bfeb39df5549504d9d298753a1d867f524e --- /dev/null +++ b/packages/ltx-trainer/configs/a2v_lora.yaml @@ -0,0 +1,344 @@ +# ============================================================================= +# LTX-2 Audio-to-Video LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# audio-to-video generation. The model learns to generate videos conditioned +# on a frozen audio signal via the transformer's built-in cross-modal attention. +# +# In this mode, audio is provided as a frozen (clean, no noise, no loss) +# conditioning signal. The video modality is the only generated output. +# Audio influences video generation through the transformer's audio-to-video +# cross-attention mechanism. +# +# Use this configuration when you want to: +# - Generate videos driven by audio content (e.g., music visualizations) +# - Train audio-reactive video generation +# - Create models that synchronize video with given audio +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (frozen 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 + # 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 audio-to-video training approach using the unified flexible strategy. +# Audio is frozen (no noise, sigma=0, excluded from loss) and conditions video +# generation via the transformer's built-in cross-modal attention. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + # Video is the generated (denoised) output + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Audio modality configuration + # Audio is frozen — it acts as conditioning for video generation + # Frozen modalities get sigma=0, timestep=0, no noise, and no loss + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + # When false, audio is passed through the transformer clean and influences + # video via cross-modal attention + is_generated: false + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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/, audio_latents/, and conditions/ + 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + A musician plays an acoustic guitar in a dimly lit recording studio, fingers moving + across the fretboard with practiced ease. The warm amber light from a desk lamp + illuminates the wooden guitar body. Sound-absorbing panels line the walls, and a + microphone stands nearby on a boom arm. + conditions: + - type: audio_to_video + audio: "/path/to/conditioning_audio_1.wav" + - prompt: >- + Rain falls steadily on a cobblestone street in a European town at dusk. Puddles form + between the stones, creating ripples as new drops land. Old brick buildings line both + sides of the narrow street, their facades glistening with moisture under warm + streetlights. + conditions: + - type: audio_to_video + audio: "/path/to/conditioning_audio_2.wav" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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_v" # "stg_av" perturbs both audio and video, "stg_v" video only + + # Whether to generate audio in validation samples + # Independent of training_strategy.audio.is_generated - you can generate audio + # in validation even when not training the audio branch + # For this audio-to-video config, false matches frozen audio conditioning (video-only synthesis). + generate_audio: false + + # Generate video from the frozen audio condition + generate_video: 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", "a2v" ] + + # Log validation media (video/audio) 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/a2v_lora" diff --git a/packages/ltx-trainer/configs/accelerate/ddp.yaml b/packages/ltx-trainer/configs/accelerate/ddp.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2edb13252bf19959630a5d1d53186ff156c84c4f --- /dev/null +++ b/packages/ltx-trainer/configs/accelerate/ddp.yaml @@ -0,0 +1,16 @@ +compute_environment: LOCAL_MACHINE +debug: false +distributed_type: MULTI_GPU +downcast_bf16: 'no' +enable_cpu_affinity: false +machine_rank: 0 +main_training_function: main +mixed_precision: bf16 +num_machines: 1 +num_processes: 4 +rdzv_backend: static +same_network: true +tpu_env: [] +tpu_use_cluster: false +tpu_use_sudo: false +use_cpu: false diff --git a/packages/ltx-trainer/configs/accelerate/ddp_compile.yaml b/packages/ltx-trainer/configs/accelerate/ddp_compile.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9398f94928d2e769ca14908011cc3ad6949ca6af --- /dev/null +++ b/packages/ltx-trainer/configs/accelerate/ddp_compile.yaml @@ -0,0 +1,21 @@ +compute_environment: LOCAL_MACHINE +dynamo_config: + dynamo_backend: INDUCTOR + dynamo_mode: default + dynamo_use_fullgraph: false + dynamo_use_dynamic: true +debug: false +distributed_type: MULTI_GPU +downcast_bf16: 'no' +enable_cpu_affinity: false +machine_rank: 0 +main_training_function: main +mixed_precision: bf16 +num_machines: 1 +num_processes: 4 +rdzv_backend: static +same_network: true +tpu_env: [ ] +tpu_use_cluster: false +tpu_use_sudo: false +use_cpu: false diff --git a/packages/ltx-trainer/configs/accelerate/fsdp.yaml b/packages/ltx-trainer/configs/accelerate/fsdp.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6a8b0028c5d5d0f01766b927577eb91ceeb1b6f5 --- /dev/null +++ b/packages/ltx-trainer/configs/accelerate/fsdp.yaml @@ -0,0 +1,29 @@ +compute_environment: LOCAL_MACHINE +debug: false +distributed_type: FSDP +downcast_bf16: 'no' +enable_cpu_affinity: false +fsdp_config: + fsdp_activation_checkpointing: false + fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP + fsdp_backward_prefetch: BACKWARD_PRE + fsdp_cpu_ram_efficient_loading: true + fsdp_forward_prefetch: false + fsdp_offload_params: false + fsdp_reshard_after_forward: FULL_SHARD + fsdp_state_dict_type: SHARDED_STATE_DICT + fsdp_sync_module_states: true + fsdp_transformer_layer_cls_to_wrap: BasicAVTransformerBlock + fsdp_use_orig_params: true + fsdp_version: 1 +machine_rank: 0 +main_training_function: main +mixed_precision: bf16 +num_machines: 1 +num_processes: 4 +rdzv_backend: static +same_network: true +tpu_env: [] +tpu_use_cluster: false +tpu_use_sudo: false +use_cpu: false diff --git a/packages/ltx-trainer/configs/accelerate/fsdp_compile.yaml b/packages/ltx-trainer/configs/accelerate/fsdp_compile.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a6b7089d9a50e5590c5360c47fdbe8b6dd5f9560 --- /dev/null +++ b/packages/ltx-trainer/configs/accelerate/fsdp_compile.yaml @@ -0,0 +1,34 @@ +compute_environment: LOCAL_MACHINE +debug: false +distributed_type: FSDP +downcast_bf16: 'no' +dynamo_config: + dynamo_backend: INDUCTOR + dynamo_mode: default + dynamo_use_fullgraph: false + dynamo_use_dynamic: true +enable_cpu_affinity: false +fsdp_config: + fsdp_activation_checkpointing: false + fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP + fsdp_backward_prefetch: BACKWARD_PRE + fsdp_cpu_ram_efficient_loading: true + fsdp_forward_prefetch: false + fsdp_offload_params: false + fsdp_reshard_after_forward: FULL_SHARD + fsdp_state_dict_type: SHARDED_STATE_DICT + fsdp_sync_module_states: true + fsdp_transformer_layer_cls_to_wrap: BasicAVTransformerBlock + fsdp_use_orig_params: true + fsdp_version: 1 +machine_rank: 0 +main_training_function: main +mixed_precision: bf16 +num_machines: 1 +num_processes: 4 +rdzv_backend: static +same_network: true +tpu_env: [] +tpu_use_cluster: false +tpu_use_sudo: false +use_cpu: false diff --git a/packages/ltx-trainer/configs/audio_extend_lora.yaml b/packages/ltx-trainer/configs/audio_extend_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fd655882a0918f0cfe483f27e795185de2444050 --- /dev/null +++ b/packages/ltx-trainer/configs/audio_extend_lora.yaml @@ -0,0 +1,343 @@ +# ============================================================================= +# LTX-2 Audio Extension (Forward) LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# forward audio extension. The model learns to continue audio forward in time +# by conditioning on a prefix of existing audio latent timesteps. +# +# Prefix conditioning works by providing the first N audio latent timesteps as +# clean conditioning signals during training — they receive no noise, +# timestep=0, and are excluded from the loss. The model learns to generate +# audio that seamlessly continues from the given prefix. +# +# This is an audio-only training mode — no video modality is configured. +# +# Use this configuration when you want to: +# - Train the model to extend/continue existing audio +# - Fine-tune audio temporal continuation capabilities +# - Create seamless audio extension models +# +# Dataset structure: +# preprocessed_data_root/ +# ├── conditions/ # Text embeddings for each sample +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 audio extension LoRA training. + 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 (not used for audio-only modes): + # - 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 (not used for audio-only modes): + # - 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 audio-only extension, we explicitly target audio modules. + # Including audio FFN layers can increase the LoRA's capacity. + target_modules: + # Audio self-attention + - "audio_attn1.to_k" + - "audio_attn1.to_q" + - "audio_attn1.to_v" + - "audio_attn1.to_out.0" + # Audio cross-attention to text + - "audio_attn2.to_k" + - "audio_attn2.to_q" + - "audio_attn2.to_v" + - "audio_attn2.to_out.0" + # Audio feed-forward (often improves transformation quality) + - "audio_ff.net.0.proj" + - "audio_ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the audio extension training approach using the unified flexible +# strategy. Prefix conditioning provides the first N audio latent timesteps as +# clean conditioning, teaching the model to generate temporal continuations. +training_strategy: + name: "flexible" + + # Audio modality configuration (audio-only, no video) + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + latents_dir: "audio_latents" + + # Conditions applied to the audio modality during training + conditions: + - type: prefix + # Number of audio latent timesteps to use as conditioning prefix + # Each audio latent timestep = 1 patchified token + temporal_boundary: 8 + # Probability of applying prefix conditioning per training sample + # At 1.0, all training samples use audio extension mode + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + offload_optimizer_during_validation: false + + +# ----------------------------------------------------------------------------- +# Data Configuration +# ----------------------------------------------------------------------------- +# Specifies the training data location and loading parameters. +data: + # Root directory containing preprocessed training data + # Should contain: conditions/ and audio_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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Each sample includes an audio prefix condition for forward audio extension. + samples: + - prompt: >- + A warm and soothing piano melody with soft ambient textures, reminiscent of a quiet + evening by the fireplace. Gentle reverb creates a sense of intimate space. + conditions: + - type: prefix + audio: "/path/to/prefix_audio_1.wav" + # Duration is in seconds; choose a value that covers a comparable audio context + # to the training temporal_boundary measured in audio latent timesteps. + duration: 1.0 + - prompt: >- + Energetic electronic beats with pulsing synthesizer leads and crisp hi-hat patterns. + Deep bass tones provide a driving rhythm underneath bright melodic arpeggios. + conditions: + - type: prefix + audio: "/path/to/prefix_audio_2.wav" + # Duration is in seconds; choose a value that covers a comparable audio context + # to the training temporal_boundary measured in audio latent timesteps. + duration: 1.0 + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # Generation length control [width, height, frames] + # With generate_video=false, width/height are unused; frames and frame_rate set audio duration. + # 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_av" # "stg_av" skips both audio and video self-attention (suited to audio validation) + + # Whether to generate audio in validation samples + # Enabled because this audio-only config generates audio + generate_audio: true + + # Whether to generate video in validation samples + # Disabled because no video modality is configured + generate_video: false + + # 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: 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", "lora", "audio-extension", "audio-only" ] + + # Log validation media (video/audio) 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/audio_extend_lora" diff --git a/packages/ltx-trainer/configs/audio_inpainting_lora.yaml b/packages/ltx-trainer/configs/audio_inpainting_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e5c8361b769dc15c6a628e24ec423837366016fa --- /dev/null +++ b/packages/ltx-trainer/configs/audio_inpainting_lora.yaml @@ -0,0 +1,324 @@ +# ============================================================================= +# LTX-2 Audio Inpainting LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# audio inpainting. The model learns to fill in masked regions of audio +# using per-sample binary masks that define which regions are conditioning +# (provided clean) and which regions the model must generate. +# +# Mask conditioning works by loading per-sample binary masks from disk. +# Masked regions receive clean latents (no noise, timestep=0) and are excluded +# from the loss. Unmasked regions are noised and trained normally. +# +# This is an audio-only training mode — no video modality is configured. +# +# Use this configuration when you want to: +# - Train the model to fill in or replace regions of existing audio +# - Fine-tune audio inpainting capabilities on custom datasets +# - Create audio restoration or editing models +# +# Dataset structure: +# preprocessed_data_root/ +# ├── conditions/ # Text embeddings for each sample +# ├── audio_latents/ # Audio latents (VAE-encoded audio) +# └── audio_masks/ # Per-sample binary masks defining conditioning regions +# +# Dataset metadata columns: audio, audio_mask, caption +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 audio inpainting LoRA training. + 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 + + # For audio-only inpainting, we explicitly target audio modules. + # Including audio FFN layers can increase the LoRA's capacity. + target_modules: + # Audio self-attention + - "audio_attn1.to_k" + - "audio_attn1.to_q" + - "audio_attn1.to_v" + - "audio_attn1.to_out.0" + # Audio cross-attention to text + - "audio_attn2.to_k" + - "audio_attn2.to_q" + - "audio_attn2.to_v" + - "audio_attn2.to_out.0" + # Audio feed-forward (often improves transformation quality) + - "audio_ff.net.0.proj" + - "audio_ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the audio inpainting training approach using the unified flexible +# strategy. Per-sample binary masks define which audio regions are provided +# as clean conditioning and which regions the model must learn to generate. +training_strategy: + name: "flexible" + + # Audio modality configuration (audio-only, no video) + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + latents_dir: "audio_latents" + + # Conditions applied to the audio modality during training + conditions: + - type: mask + # Directory name (within preprocessed_data_root) containing binary masks + # Each mask file corresponds to a training sample and defines the + # conditioning region (mask=1 means conditioning, mask=0 means generate) + mask_dir: "audio_masks" + # Probability of applying mask conditioning per training sample + # At 1.0, all training samples use inpainting mode + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + offload_optimizer_during_validation: false + + +# ----------------------------------------------------------------------------- +# Data Configuration +# ----------------------------------------------------------------------------- +# Specifies the training data location and loading parameters. +data: + # Root directory containing preprocessed training data + # Should contain: conditions/, audio_latents/, and audio_masks/ 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # For audio inpainting, each sample includes mask conditioning (audio + mask paths). + samples: + - prompt: >- + A warm and soothing piano melody with soft ambient textures, reminiscent of a quiet + evening by the fireplace. Gentle reverb creates a sense of intimate space. + conditions: + - type: mask + audio: "/path/to/inpainting_audio_1.wav" + mask: "/path/to/inpainting_mask_1.pt" + - prompt: >- + Energetic electronic beats with pulsing synthesizer leads and crisp hi-hat patterns. + Deep bass tones provide a driving rhythm underneath bright melodic arpeggios. + conditions: + - type: mask + audio: "/path/to/inpainting_audio_2.wav" + mask: "/path/to/inpainting_mask_2.pt" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # Generation length control [width, height, frames] + # With generate_video=false, width/height are unused; frames and frame_rate set audio duration. + # 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_av" # "stg_av" skips both audio and video self-attention (suited to audio validation) + + # Whether to generate audio in validation samples + # Enabled because this audio-only config generates audio + generate_audio: true + + # Whether to generate video in validation samples + # Disabled because no video modality is configured + generate_video: false + + # 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: 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", "lora", "audio-inpainting", "audio-only" ] + + # Log validation media (video/audio) 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/audio_inpainting_lora" diff --git a/packages/ltx-trainer/configs/audio_suffix_lora.yaml b/packages/ltx-trainer/configs/audio_suffix_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9ef48a1f6f667739d8de228cf76437921f49b42e --- /dev/null +++ b/packages/ltx-trainer/configs/audio_suffix_lora.yaml @@ -0,0 +1,343 @@ +# ============================================================================= +# LTX-2 Audio Extension (Backward) LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# backward audio extension. The model learns to generate audio content leading into existing audio +# by conditioning on a suffix of existing audio latent timesteps. +# +# Suffix conditioning works by providing the last N audio latent timesteps as +# clean conditioning signals during training — they receive no noise, +# timestep=0, and are excluded from the loss. The model learns to generate +# audio that seamlessly leads into the given suffix. +# +# This is an audio-only training mode — no video modality is configured. +# +# Use this configuration when you want to: +# - Train the model to generate preceding content for existing audio +# - Fine-tune audio temporal continuation capabilities +# - Create seamless audio extension models +# +# Dataset structure: +# preprocessed_data_root/ +# ├── conditions/ # Text embeddings for each sample +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 audio extension LoRA training. + 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 (not used for audio-only modes): + # - 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 (not used for audio-only modes): + # - 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 audio-only extension, we explicitly target audio modules. + # Including audio FFN layers can increase the LoRA's capacity. + target_modules: + # Audio self-attention + - "audio_attn1.to_k" + - "audio_attn1.to_q" + - "audio_attn1.to_v" + - "audio_attn1.to_out.0" + # Audio cross-attention to text + - "audio_attn2.to_k" + - "audio_attn2.to_q" + - "audio_attn2.to_v" + - "audio_attn2.to_out.0" + # Audio feed-forward (often improves transformation quality) + - "audio_ff.net.0.proj" + - "audio_ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the backward audio extension training approach using the unified flexible +# strategy. Suffix conditioning provides the last N audio latent timesteps as +# clean conditioning, teaching the model to generate content leading into existing audio. +training_strategy: + name: "flexible" + + # Audio modality configuration (audio-only, no video) + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + latents_dir: "audio_latents" + + # Conditions applied to the audio modality during training + conditions: + - type: suffix + # Number of audio latent timesteps to use as conditioning suffix + # Each audio latent timestep = 1 patchified token + temporal_boundary: 8 + # Probability of applying suffix conditioning per training sample + # At 1.0, all training samples use audio extension mode + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + offload_optimizer_during_validation: false + + +# ----------------------------------------------------------------------------- +# Data Configuration +# ----------------------------------------------------------------------------- +# Specifies the training data location and loading parameters. +data: + # Root directory containing preprocessed training data + # Should contain: conditions/ and audio_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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Each sample includes an audio suffix condition for backward audio extension. + samples: + - prompt: >- + A warm and soothing piano melody with soft ambient textures, reminiscent of a quiet + evening by the fireplace. Gentle reverb creates a sense of intimate space. + conditions: + - type: suffix + audio: "/path/to/suffix_audio_1.wav" + # Duration is in seconds; choose a value that covers a comparable audio context + # to the training temporal_boundary measured in audio latent timesteps. + duration: 1.0 + - prompt: >- + Energetic electronic beats with pulsing synthesizer leads and crisp hi-hat patterns. + Deep bass tones provide a driving rhythm underneath bright melodic arpeggios. + conditions: + - type: suffix + audio: "/path/to/suffix_audio_2.wav" + # Duration is in seconds; choose a value that covers a comparable audio context + # to the training temporal_boundary measured in audio latent timesteps. + duration: 1.0 + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # Generation length control [width, height, frames] + # With generate_video=false, width/height are unused; frames and frame_rate set audio duration. + # 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_av" # "stg_av" skips both audio and video self-attention (suited to audio validation) + + # Whether to generate audio in validation samples + # Enabled because this audio-only config generates audio + generate_audio: true + + # Whether to generate video in validation samples + # Disabled because no video modality is configured + generate_video: false + + # 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: 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", "lora", "audio-suffix", "audio-only" ] + + # Log validation media (video/audio) 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/audio_suffix_lora" diff --git a/packages/ltx-trainer/configs/av2av_ic_lora.yaml b/packages/ltx-trainer/configs/av2av_ic_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..78d12c9b0fadc03041a5a20337bd0b2b7828ae22 --- /dev/null +++ b/packages/ltx-trainer/configs/av2av_ic_lora.yaml @@ -0,0 +1,342 @@ +# ============================================================================= +# LTX-2 AV2AV IC-LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training In-Context LoRA (IC-LoRA) adapters that +# enable joint audio-video-to-audio-video transformations. IC-LoRA learns to +# apply transformations to both the video and audio modalities simultaneously +# by conditioning on paired reference video and audio. +# +# Both modalities use reference conditioning: pre-encoded reference latents +# are concatenated to each modality's target sequence. Reference tokens +# participate in bidirectional self-attention but receive no noise and are +# excluded from the loss. +# +# Key differences from video-only IC-LoRA (v2v_ic_lora.yaml): +# - Both video AND audio have reference conditions +# - Requires preprocessed reference latents for BOTH modalities +# - LoRA targets all modules (video, audio, and cross-modal attention) +# - Validation uses both video and audio reference conditions +# +# 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) +# +# Dataset metadata columns: video, audio, reference_video, reference_audio, caption +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 + # IC-LoRA reference conditioning is intended for LoRA adapter training. + 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 + + # For AV2AV IC-LoRA, we target ALL modules — video, audio, and cross-modal attention. + # Using short patterns matches all branches simultaneously. + target_modules: + # Attention layers (matches video, audio, and cross-modal branches) + - "to_k" + - "to_q" + - "to_v" + - "to_out.0" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the AV2AV IC-LoRA training approach using the unified flexible +# strategy. Both video and audio modalities have reference conditioning, +# enabling joint audiovisual transformations. +training_strategy: + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing target video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + conditions: + # Reference conditioning (IC-LoRA): concatenates pre-encoded reference video + # latents to the target sequence + - type: reference + latents_dir: "reference_latents" + probability: 1.0 + + # Audio modality configuration + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing target audio latents + latents_dir: "audio_latents" + + # Conditions applied to the audio modality during training + conditions: + # Reference conditioning (IC-LoRA): concatenates pre-encoded reference audio + # latents to the target sequence + - type: reference + latents_dir: "reference_audio_latents" + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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/, audio_latents/, conditions/, reference_latents/, and reference_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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # For AV2AV IC-LoRA, each sample includes reference video and reference audio conditions. + samples: + - prompt: >- + 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. + conditions: + - type: reference + video: "/path/to/reference_video_1.mp4" + downscale_factor: 1 + temporal_scale_factor: 1 + include_in_output: true + - type: reference + audio: "/path/to/reference_audio_1.wav" + - prompt: >- + 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. + conditions: + - type: reference + video: "/path/to/reference_video_2.mp4" + downscale_factor: 1 + temporal_scale_factor: 1 + include_in_output: true + - type: reference + audio: "/path/to/reference_audio_2.wav" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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_av" # Both video and audio modalities are trained + + # Whether to generate audio in validation samples + # Can be enabled 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: 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", "av2av" ] + + # Log validation media (video/audio) 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/av2av_ic_lora" diff --git a/packages/ltx-trainer/configs/i2v_lora.yaml b/packages/ltx-trainer/configs/i2v_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..373a7911327b0f59af7e2d13990735bbc66f3cad --- /dev/null +++ b/packages/ltx-trainer/configs/i2v_lora.yaml @@ -0,0 +1,348 @@ +# ============================================================================= +# LTX-2 Image-to-Video LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# image-to-video generation. The model learns to generate videos conditioned +# on a starting image (first frame), with optional audio generation. +# +# First-frame conditioning works by providing the first frame as a clean +# conditioning signal during training — it receives no noise, timestep=0, +# and is excluded from the loss. This teaches the model to animate from +# a given image. +# +# Use this configuration when you want to: +# - Train the model to generate videos starting from a given image +# - Fine-tune image-to-video capabilities on custom datasets +# - Create image animation models with optional audio +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 image-to-video training approach using the unified flexible strategy. +# First-frame conditioning provides the first frame as clean conditioning signal +# during training, teaching the model to animate from a given image. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + # First-frame conditioning: the first frame of each video is provided as a clean + # conditioning signal (no noise, timestep=0, excluded from loss) + conditions: + - type: first_frame + # Probability of applying first-frame conditioning per training sample + # At 0.5, half the training samples use I2V mode, half use pure T2V + # Higher values improve I2V quality but may reduce T2V diversity + probability: 0.5 + + # Audio modality configuration + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + conditions: + - type: first_frame + image_or_video: "/path/to/conditioning_image_1.png" + - prompt: >- + 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. + conditions: + - type: first_frame + image_or_video: "/path/to/conditioning_image_2.png" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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.audio.is_generated - 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", "i2v" ] + + # Log validation media (video/audio) 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/i2v_lora" diff --git a/packages/ltx-trainer/configs/t2a_lora.yaml b/packages/ltx-trainer/configs/t2a_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..641a9bcdcc26c15004639c310b69e772878d2eeb --- /dev/null +++ b/packages/ltx-trainer/configs/t2a_lora.yaml @@ -0,0 +1,317 @@ +# ============================================================================= +# LTX-2 Text-to-Audio LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# text-to-audio generation. The model learns to generate audio from text +# prompts without any additional conditioning. +# +# This is the simplest audio-only training mode — no reference audio, no +# video modality. Only the audio branch of the transformer is trained. +# +# Use this configuration when you want to: +# - Fine-tune audio generation for specific sound styles or domains +# - Train custom audio generation capabilities +# - Create audio LoRAs that can be combined with video LoRAs +# +# Dataset structure: +# preprocessed_data_root/ +# ├── conditions/ # Text embeddings for each sample +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 audio LoRA training. + 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 (not used for audio-only modes): + # - 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 (not used for audio-only modes): + # - 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 audio-only training, we explicitly target audio modules. + # Including audio FFN layers can increase the LoRA's capacity. + target_modules: + # Audio self-attention + - "audio_attn1.to_k" + - "audio_attn1.to_q" + - "audio_attn1.to_v" + - "audio_attn1.to_out.0" + # Audio cross-attention to text + - "audio_attn2.to_k" + - "audio_attn2.to_q" + - "audio_attn2.to_v" + - "audio_attn2.to_out.0" + # Audio feed-forward (often improves transformation quality) + - "audio_ff.net.0.proj" + - "audio_ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the text-to-audio training approach using the unified flexible +# strategy. Audio-only training with no additional conditions — the model +# learns to generate audio purely from text prompts. +training_strategy: + name: "flexible" + + # Audio modality configuration (audio-only, no video) + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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: 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + offload_optimizer_during_validation: false + + +# ----------------------------------------------------------------------------- +# Data Configuration +# ----------------------------------------------------------------------------- +# Specifies the training data location and loading parameters. +data: + # Root directory containing preprocessed training data + # Should contain: conditions/ and audio_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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Text-to-audio validation samples do not need additional conditions. + samples: + - prompt: >- + A warm and soothing piano melody with soft ambient textures, reminiscent of a quiet + evening by the fireplace. Gentle reverb creates a sense of intimate space. + - prompt: >- + Energetic electronic beats with pulsing synthesizer leads and crisp hi-hat patterns. + Deep bass tones provide a driving rhythm underneath bright melodic arpeggios. + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # Generation length control [width, height, frames] + # With generate_video=false, width/height are unused; frames and frame_rate set audio duration. + # 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_av" # "stg_av" skips both audio and video self-attention (suited to audio validation) + + # Whether to generate audio in validation samples + # Enabled because this audio-only config generates audio + generate_audio: true + + # Whether to generate video in validation samples + # Disabled because no video modality is configured + generate_video: false + + # 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: 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", "lora", "t2a", "audio-only" ] + + # Log validation media (video/audio) 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/t2a_lora" diff --git a/packages/ltx-trainer/configs/t2v_lora.yaml b/packages/ltx-trainer/configs/t2v_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5d8fdae6ab3d78d111319249c9ccdf7c571908bf --- /dev/null +++ b/packages/ltx-trainer/configs/t2v_lora.yaml @@ -0,0 +1,327 @@ +# ============================================================================= +# LTX-2 Text-to-Video LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# text-to-video generation with joint audio-video support. +# +# Use this configuration when you want to: +# - Fine-tune LTX-2 on your own video dataset +# - Train joint audio-video generation from text prompts +# - Create custom video generation styles or audiovisual concepts +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 training approach using the unified flexible strategy. +# This configuration trains both video and audio generation from text prompts. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + # When is_generated is true, the model learns to generate (denoise) video + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Audio modality configuration + # When is_generated is true, the model learns to generate (denoise) audio + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + - prompt: >- + 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" + + # 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.audio.is_generated - 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", "t2v" ] + + # Log validation media (video/audio) 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/t2v_lora" diff --git a/packages/ltx-trainer/configs/t2v_lora_low_vram.yaml b/packages/ltx-trainer/configs/t2v_lora_low_vram.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2d2b434fd9d7fe5da52055e61563d588b2f58f33 --- /dev/null +++ b/packages/ltx-trainer/configs/t2v_lora_low_vram.yaml @@ -0,0 +1,339 @@ +# ============================================================================= +# LTX-2 Text-to-Video LoRA Training Configuration (Low VRAM) +# ============================================================================= +# +# This is a memory-optimized variant of the standard text-to-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 with limited GPU memory +# - Train joint audio-video generation from text prompts +# - Create custom video generation styles or audiovisual concepts +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 training approach using the unified flexible strategy. +# This configuration trains both video and audio generation from text prompts. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + # When is_generated is true, the model learns to generate (denoise) video + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Audio modality configuration + # When is_generated is true, the model learns to generate (denoise) audio + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + - prompt: >- + 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" + + # 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 audio modality settings — 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", "t2v", "low-vram" ] + + # Log validation media (video/audio) 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/t2v_lora_low_vram" diff --git a/packages/ltx-trainer/configs/v2a_lora.yaml b/packages/ltx-trainer/configs/v2a_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cc7231f28ae5bad08299fc8c12adab2f255d7ec8 --- /dev/null +++ b/packages/ltx-trainer/configs/v2a_lora.yaml @@ -0,0 +1,349 @@ +# ============================================================================= +# LTX-2 Video-to-Audio (Foley) LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# video-to-audio (Foley) generation. The model learns to generate audio +# conditioned on a frozen video signal via the transformer's built-in +# cross-modal attention. +# +# In this mode, video is provided as a frozen (clean, no noise, no loss) +# conditioning signal. The audio modality is the only generated output. +# Video influences audio generation through the transformer's video-to-audio +# cross-attention mechanism. +# +# Use this configuration when you want to: +# - Generate sound effects (Foley) for existing videos +# - Train audio generation conditioned on visual content +# - Create models that produce audio matching video content +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (frozen conditioning input) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio, generated output) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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) + # + # For video-to-audio training, we target audio modules and the cross-modal + # attention that allows audio to attend to video features. + target_modules: + # Audio self-attention + - "audio_attn1.to_k" + - "audio_attn1.to_q" + - "audio_attn1.to_v" + - "audio_attn1.to_out.0" + # Audio cross-attention to text + - "audio_attn2.to_k" + - "audio_attn2.to_q" + - "audio_attn2.to_v" + - "audio_attn2.to_out.0" + # Audio feed-forward + - "audio_ff.net.0.proj" + - "audio_ff.net.2" + # Cross-modal attention: allows audio to attend to video 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" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the video-to-audio (Foley) training approach using the unified +# flexible strategy. Video is frozen (no noise, sigma=0, excluded from loss) +# and conditions audio generation via the transformer's cross-modal attention. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + # Video is frozen — it acts as conditioning for audio generation + # Frozen modalities get sigma=0, timestep=0, no noise, and no loss + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + # When false, video is passed through the transformer clean and influences + # audio via cross-modal attention + is_generated: false + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Audio modality configuration + # Audio is the generated (denoised) output + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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/, audio_latents/, and conditions/ + 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + The sound of ocean waves crashing against rocky cliffs, with seagulls calling in the + distance and wind whistling through coastal grass. + conditions: + - type: video_to_audio + video: "/path/to/conditioning_video_1.mp4" + - prompt: >- + Footsteps echo in a marble hallway as a person walks steadily, with the distant hum of + air conditioning and occasional door closing sounds. + conditions: + - type: video_to_audio + video: "/path/to/conditioning_video_2.mp4" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # Validation dimensions [width, height, frames] + # Width/height resize the frozen video condition; frames and frame_rate set audio duration. + # 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.audio.is_generated - you can generate audio + # in validation even when not training the audio branch + generate_audio: true + + # Whether to generate video in validation samples + # Disabled for V2A since video is frozen conditioning, not generated + generate_video: false + + # 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", "v2a", "foley" ] + + # Log validation media (video/audio) 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/v2a_lora" diff --git a/packages/ltx-trainer/configs/v2v_ic_lora.yaml b/packages/ltx-trainer/configs/v2v_ic_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..349e4b3e462c8979f6b71a9d2a0be2d071b11aeb --- /dev/null +++ b/packages/ltx-trainer/configs/v2v_ic_lora.yaml @@ -0,0 +1,359 @@ +# ============================================================================= +# 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) +# +# Dataset metadata columns: video, reference_video, caption +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 + # IC-LoRA reference conditioning is intended for LoRA adapter training. + 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 using the unified +# flexible strategy. Reference conditioning concatenates pre-encoded reference +# video latents to the target sequence. Reference tokens participate in +# bidirectional self-attention but receive no noise and are excluded from loss. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing target video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + conditions: + # Reference conditioning (IC-LoRA): concatenates pre-encoded reference video + # latents to the target sequence. The model learns to transform the reference + # into the target video based on the text prompt. + - type: reference + # Directory name (within preprocessed_data_root) containing reference video latents + # These are the conditioning inputs that guide the transformation + latents_dir: "reference_latents" + # Probability of applying reference conditioning per training sample + probability: 1.0 + + # Optional first-frame conditioning to improve I2V capabilities + # At low probability, this teaches the model to also accept first-frame input + - type: first_frame + probability: 0.2 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # For IC-LoRA, each sample includes a reference condition pointing to the conditioning video. + samples: + - prompt: >- + 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. + conditions: + - type: reference + video: "/path/to/reference_video_1.mp4" + # Set these to match --reference-downscale-factor / --reference-temporal-scale-factor + # if reference latents were preprocessed at reduced spatial or temporal resolution. + downscale_factor: 1 + temporal_scale_factor: 1 + include_in_output: true + - prompt: >- + 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. + conditions: + - type: reference + video: "/path/to/reference_video_2.mp4" + # Set these to match --reference-downscale-factor / --reference-temporal-scale-factor + # if reference latents were preprocessed at reduced spatial or temporal resolution. + downscale_factor: 1 + temporal_scale_factor: 1 + include_in_output: true + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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 + +# ----------------------------------------------------------------------------- +# 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", "v2v" ] + + # Log validation media (video/audio) 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/v2v_ic_lora" diff --git a/packages/ltx-trainer/configs/video_extend_lora.yaml b/packages/ltx-trainer/configs/video_extend_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..39a2d4f5ff7e3862582c60824dcaf67eb7b2bbdf --- /dev/null +++ b/packages/ltx-trainer/configs/video_extend_lora.yaml @@ -0,0 +1,355 @@ +# ============================================================================= +# LTX-2 Video Extension LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# video extension (temporal continuation). The model learns to extend a video +# forward in time by conditioning on a prefix of existing frames. +# +# Prefix conditioning works by providing the first N temporal units as clean +# conditioning signals during training — they receive no noise, timestep=0, +# and are excluded from the loss. The model learns to generate continuation +# frames that seamlessly follow the given prefix. +# +# Use this configuration when you want to: +# - Train the model to extend/continue existing videos +# - Fine-tune temporal continuation capabilities on custom datasets +# - Create seamless video extension models with optional audio +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 video extension training approach using the unified flexible strategy. +# Prefix conditioning provides the first N latent frames as clean conditioning, +# teaching the model to generate temporally coherent continuations. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + # Prefix conditioning: the first N temporal units are provided as clean + # conditioning signals (no noise, timestep=0, excluded from loss) + conditions: + - type: prefix + # Number of latent frames to use as conditioning prefix + # For prefix conditioning, N latent frames correspond to (N - 1) * 8 + 1 pixel frames. + # temporal_boundary=8 means 57 pixel frames are used as prefix. + temporal_boundary: 8 + # Probability of applying prefix conditioning per training sample + # At 1.0, all training samples use video extension mode + probability: 1.0 + + # Audio modality configuration + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + conditions: + - type: prefix + video: "/path/to/prefix_video_1.mp4" + # Matches temporal_boundary=8 during training: (8 - 1) * 8 + 1 = 57 frames + num_frames: 57 + - prompt: >- + 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. + conditions: + - type: prefix + video: "/path/to/prefix_video_2.mp4" + # Matches temporal_boundary=8 during training: (8 - 1) * 8 + 1 = 57 frames + num_frames: 57 + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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.audio.is_generated - 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", "video-extension" ] + + # Log validation media (video/audio) 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/video_extend_lora" diff --git a/packages/ltx-trainer/configs/video_inpainting_lora.yaml b/packages/ltx-trainer/configs/video_inpainting_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..46829b38dff6b7a6799d659c8aa4d62abe0a6f00 --- /dev/null +++ b/packages/ltx-trainer/configs/video_inpainting_lora.yaml @@ -0,0 +1,343 @@ +# ============================================================================= +# LTX-2 Video Inpainting LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# video inpainting. The model learns to fill in masked regions of a video +# using per-sample binary masks that define which regions are conditioning +# (provided clean) and which regions the model must generate. +# +# Mask conditioning works by loading per-sample binary masks from disk. +# Masked regions receive clean latents (no noise, timestep=0) and are excluded +# from the loss. Unmasked regions are noised and trained normally. +# +# Use this configuration when you want to: +# - Train the model to fill in or replace regions of existing videos +# - Fine-tune video inpainting capabilities on custom datasets +# - Create object removal or region editing models +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── video_masks/ # Per-sample binary masks defining conditioning regions +# +# Dataset metadata columns: video, video_mask, caption +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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) + # + # This is a video-only config, so explicitly target video attention modules. + 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" + # Uncomment below to also train feed-forward layers (can increase the LoRA's capacity): + # - "ff.net.0.proj" + # - "ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the video inpainting training approach using the unified flexible +# strategy. Per-sample binary masks define which regions are provided as clean +# conditioning and which regions the model must learn to generate. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + # Mask conditioning: per-sample binary masks loaded from disk define which + # tokens are conditioning (clean, timestep=0, no loss) vs generated + conditions: + - type: mask + # Directory name (within preprocessed_data_root) containing binary masks + # Each mask file corresponds to a training sample and defines the + # conditioning region (mask=1 means conditioning, mask=0 means generate) + mask_dir: "video_masks" + # Probability of applying mask conditioning per training sample + # At 1.0, all training samples use inpainting mode + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 video_masks/ + 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + conditions: + - type: mask + video: "/path/to/inpainting_video_1.mp4" + mask: "/path/to/inpainting_mask_1.mp4" + - prompt: >- + 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. + conditions: + - type: mask + video: "/path/to/inpainting_video_2.mp4" + mask: "/path/to/inpainting_mask_2.mp4" + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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_v" # "stg_v" for video-only validation + + # Video inpainting trains video only; do not generate validation audio. + generate_audio: false + + # 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", "inpainting" ] + + # Log validation outputs 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/video_inpainting_lora" diff --git a/packages/ltx-trainer/configs/video_outpainting_lora.yaml b/packages/ltx-trainer/configs/video_outpainting_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e26518aab24f4c2aacc51db9100f548a0d789845 --- /dev/null +++ b/packages/ltx-trainer/configs/video_outpainting_lora.yaml @@ -0,0 +1,341 @@ +# ============================================================================= +# LTX-2 Video Outpainting LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# video outpainting (spatial extension). The model learns to generate content +# surrounding a known rectangular region of the video. +# +# Spatial crop conditioning works by providing a rectangular pixel region as +# clean conditioning during training — those tokens receive no noise, +# timestep=0, and are excluded from the loss. The model learns to generate +# the content outside the given region, seamlessly extending the scene. +# +# Use this configuration when you want to: +# - Extend video content beyond its original boundaries +# - Train spatial outpainting capabilities on custom datasets +# - Create video aspect ratio conversion models +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# └── conditions/ # Text embeddings for each video +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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) + # + # This is a video-only config, so explicitly target video attention modules. + 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" + # Uncomment below to also train feed-forward layers (can increase the LoRA's capacity): + # - "ff.net.0.proj" + # - "ff.net.2" + +# ----------------------------------------------------------------------------- +# Training Strategy Configuration +# ----------------------------------------------------------------------------- +# Defines the video outpainting training approach using the unified flexible +# strategy. Spatial crop conditioning provides a rectangular pixel region as +# clean conditioning, teaching the model to generate surrounding content. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + # Spatial crop conditioning provides a rectangular region as clean context; + # the model learns to generate the surrounding video tokens. + conditions: + - type: spatial_crop + # Rectangular pixel region provided as clean conditioning (y1, x1, y2, x2) + # Pixels within this region are conditioning (clean, timestep=0, no loss) + # Pixels outside this region are generated by the model + # Coordinates are in pixel space — automatically converted to latent space + spatial_region: [0, 0, 288, 576] + # Probability of applying spatial crop conditioning per training sample + # At 1.0, all training samples use outpainting mode + probability: 1.0 + +# ----------------------------------------------------------------------------- +# 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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/ and conditions/ + 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + conditions: + - type: spatial_crop + video: "/path/to/outpainting_video_1.mp4" + spatial_region: [0, 0, 288, 576] + - prompt: >- + 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. + conditions: + - type: spatial_crop + video: "/path/to/outpainting_video_2.mp4" + spatial_region: [0, 0, 288, 576] + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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_v" # "stg_v" for video-only validation + + # Video outpainting trains video only; do not generate validation audio. + generate_audio: false + + # 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", "outpainting", "spatial-crop" ] + + # Log validation outputs 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/video_outpainting_lora" diff --git a/packages/ltx-trainer/configs/video_suffix_lora.yaml b/packages/ltx-trainer/configs/video_suffix_lora.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b9617f19caf3a9897c020907158dc8ef3d5e39a8 --- /dev/null +++ b/packages/ltx-trainer/configs/video_suffix_lora.yaml @@ -0,0 +1,355 @@ +# ============================================================================= +# LTX-2 Video Backward Extension (Suffix) LoRA Training Configuration +# ============================================================================= +# +# This configuration is for training LoRA adapters on the LTX-2 model for +# backward video extension. The model learns to generate video content that +# leads into a given suffix of existing frames. +# +# Suffix conditioning works by providing the last N temporal units as clean +# conditioning signals during training — they receive no noise, timestep=0, +# and are excluded from the loss. The model learns to generate preceding +# frames that seamlessly lead into the given suffix. +# +# Use this configuration when you want to: +# - Train the model to generate "prequel" content for existing videos +# - Fine-tune backward temporal continuation capabilities +# - Create video backward extension models with optional audio +# +# Dataset structure: +# preprocessed_data_root/ +# ├── latents/ # Video latents (VAE-encoded videos) +# ├── conditions/ # Text embeddings for each video +# └── audio_latents/ # Audio latents (VAE-encoded audio) +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 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 backward video extension training approach using the unified +# flexible strategy. Suffix conditioning provides the last N latent frames as +# clean conditioning, teaching the model to generate content leading into them. +training_strategy: + # Strategy name: "flexible" for the unified conditioning framework + # Supports all training modes (T2V, I2V, V2V, A2V, V2A, etc.) through + # modality-specific configuration blocks. + name: "flexible" + + # Video modality configuration + video: + # Whether the model generates video (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing video latents + latents_dir: "latents" + + # Conditions applied to the video modality during training + # Suffix conditioning: the last N temporal units are provided as clean + # conditioning signals (no noise, timestep=0, excluded from loss) + conditions: + - type: suffix + # Number of latent frames to use as conditioning suffix + # For suffix conditioning, N latent frames correspond to N * 8 pixel frames. + # temporal_boundary=8 means the last 64 pixel frames are used as suffix. + temporal_boundary: 8 + # Probability of applying suffix conditioning per training sample + # At 1.0, all training samples use backward extension mode + probability: 1.0 + + # Audio modality configuration + audio: + # Whether the model generates audio (true) or uses it as frozen conditioning (false) + is_generated: true + # Directory name (within preprocessed_data_root) containing audio latents + 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 and restore it after. + # Frees VRAM for the VAE decoder when optimizer state is large (full fine-tune, high-rank + # LoRA). No effect under FSDP (sharded state). + 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 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 sampling during training. +# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over +# maximum quality. For production-quality inference, use `packages/ltx-pipelines`. +validation: + # Validation samples — each sample describes a self-contained generation request. + # Use 'conditions' to add conditioning (first_frame, prefix, suffix, reference, video_to_audio, etc.) + # See docs/configuration-reference.md#validation-condition-types for the full list of condition types. + samples: + - prompt: >- + 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. + conditions: + - type: suffix + video: "/path/to/suffix_video_1.mp4" + # Matches temporal_boundary=8 during training: 8 * 8 = 64 frames + num_frames: 64 + - prompt: >- + 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. + conditions: + - type: suffix + video: "/path/to/suffix_video_2.mp4" + # Matches temporal_boundary=8 during training: 8 * 8 = 64 frames + num_frames: 64 + + # Negative prompt to avoid unwanted artifacts + negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" + + # 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.audio.is_generated - 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", "video-suffix", "backward-extension" ] + + # Log validation media (video/audio) 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/video_suffix_lora" diff --git a/packages/ltx-trainer/docs/configuration-reference.md b/packages/ltx-trainer/docs/configuration-reference.md new file mode 100644 index 0000000000000000000000000000000000000000..e9d17bec80c88143f8a923004fb95b8718dcc071 --- /dev/null +++ b/packages/ltx-trainer/docs/configuration-reference.md @@ -0,0 +1,464 @@ +# Configuration Reference + +The trainer uses structured Pydantic models for configuration, making it easy to customize training parameters. +This guide covers all available configuration options and their usage. + +## 📋 Overview + +The main configuration class is [`LtxTrainerConfig`](../src/ltx_trainer/config.py), which includes the following +sub-configurations: + +- **ModelConfig**: Base model and training mode settings +- **LoraConfig**: LoRA training parameters +- **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 +- **ValidationConfig**: Validation and inference settings +- **CheckpointsConfig**: Checkpoint saving frequency and retention settings +- **HubConfig**: Hugging Face Hub integration settings +- **WandbConfig**: Weights & Biases logging settings +- **FlowMatchingConfig**: Timestep sampling parameters + +## 📄 Example Configuration Files + +Check out our example configurations in the `configs` directory: + +- 📄 [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. + +```yaml +model: + model_path: "/path/to/ltx-2-model.safetensors" # Local path to model checkpoint + text_encoder_path: "/path/to/gemma-model" # Path to Gemma text encoder directory + training_mode: "lora" # "lora" or "full" + load_checkpoint: null # Path to checkpoint to resume from +``` + +**Key parameters:** + +| Parameter | Description | +|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `model_path` | **Required.** Local path to the LTX-2 model checkpoint (`.safetensors` file). URLs are not supported. | +| `text_encoder_path` | **Required.** Path to the Gemma text encoder model directory. Download from [HuggingFace](https://huggingface.co/google/gemma-3-12b-it-qat-q4_0-unquantized/). | +| `training_mode` | Training approach - `"lora"` for LoRA training or `"full"` for full-rank fine-tuning. | +| `load_checkpoint` | Optional path to resume training from a checkpoint file or directory. | + +> [!NOTE] +> LTX-2 requires both a model checkpoint and a Gemma text encoder. Both must be local paths. + +### LoraConfig + +LoRA-specific fine-tuning parameters (only used when `training_mode: "lora"`). + +```yaml +lora: + rank: 32 # LoRA rank (higher = more parameters) + alpha: 32 # LoRA alpha scaling factor + dropout: 0.0 # Dropout probability (0.0-1.0) + target_modules: # Modules to apply LoRA to + - "to_k" + - "to_q" + - "to_v" + - "to_out.0" +``` + +**Key parameters:** + +| Parameter | Description | +|------------------|---------------------------------------------------------------------------------| +| `rank` | LoRA rank - higher values mean more trainable parameters (typical range: 8-128) | +| `alpha` | Alpha scaling factor - typically set equal to rank | +| `dropout` | Dropout probability for regularization | +| `target_modules` | List of transformer modules to apply LoRA adapters to (see below) | + +#### Understanding Target Modules + +The LTX-2 transformer has separate attention and feed-forward blocks for video and audio, as well as cross-attention +modules that enable the two modalities to exchange information. Choosing the right `target_modules` is critical for +achieving good results, especially when training with audio. + +**Video-only modules:** + +| Module Pattern | Description | +|------------------------------------------------------------|---------------------------------| +| `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 network | + +**Audio-only modules:** + +| Module Pattern | Description | +|------------------------------------------------------------------------------------|---------------------------------| +| `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 network | + +**Audio-video cross-attention modules:** + +These modules enable bidirectional information flow between the audio and video modalities: + +| Module Pattern | Description | +|--------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------| +| `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` | Video attends to audio (Q from video, K/V from audio) | +| `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` | Audio attends to video (Q from audio, K/V from video) | + +**Recommended configurations:** + +For **video-only training**, target the video attention layers: + +```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" +``` + +For **audio-video training**, use patterns that match both branches: + +```yaml +target_modules: + - "to_k" + - "to_q" + - "to_v" + - "to_out.0" +``` + +> [!NOTE] +> Using shorter patterns like `"to_k"` will match all attention modules including `attn1.to_k`, `audio_attn1.to_k`, +> `audio_to_video_attn.to_k`, and `video_to_audio_attn.to_k`, effectively training video, audio, and cross-modal +> attention branches together. + +> [!TIP] +> You can also target the feed-forward (FFN) modules (`ff.net.0.proj`, `ff.net.2` for video, +> `audio_ff.net.0.proj`, `audio_ff.net.2` for audio) to increase the LoRA's capacity and potentially +> help it capture the target distribution better. + +### TrainingStrategyConfig + +Configures the training strategy. The recommended strategy is `"flexible"`, which supports all conditioning scenarios through configuration. + +#### 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: "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) +``` + +**ModalityConfig parameters:** + +| 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. | + +**Condition types:** + +| 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 + +Training optimization parameters including learning rates, batch sizes, and schedulers. + +```yaml +optimization: + learning_rate: 1e-4 # Learning rate + steps: 2000 # Total training steps + batch_size: 1 # Batch size per GPU + gradient_accumulation_steps: 1 # Steps to accumulate gradients + max_grad_norm: 1.0 # Gradient clipping threshold + optimizer_type: "adamw" # "adamw" or "adamw8bit" + scheduler_type: "linear" # Scheduler type + scheduler_params: { } # Additional scheduler parameters + enable_gradient_checkpointing: true # Memory optimization +``` + +**Key parameters:** + +| Parameter | Description | +|---------------------------------|----------------------------------------------------------------------------------------------| +| `learning_rate` | Learning rate for optimization (typical range: 1e-5 to 1e-3) | +| `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"`, `"step"` | +| `enable_gradient_checkpointing` | Trade training speed for GPU memory savings (recommended for large models) | + +### AccelerationConfig + +Hardware acceleration and compute optimization settings. + +```yaml +acceleration: + mixed_precision_mode: "bf16" # "no", "fp16", or "bf16" + quantization: null # Quantization options + load_text_encoder_in_8bit: false # Load text encoder in 8-bit + offload_optimizer_during_validation: false # Offload optimizer state to CPU during validation +``` + +**Key parameters:** + +| Parameter | Description | +|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mixed_precision_mode` | Precision mode - `"bf16"` recommended for modern GPUs | +| `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. | + +### DataConfig + +Data loading and processing configuration. + +```yaml +data: + preprocessed_data_root: "/path/to/preprocessed/data" # Path to precomputed dataset + num_dataloader_workers: 2 # Background data loading workers +``` + +**Key parameters:** + +| Parameter | Description | +|--------------------------|--------------------------------------------------------------------------------------------| +| `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 samples use a self-describing +format where each sample specifies its own prompt and conditions. + +```yaml +validation: + 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 | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `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 + +Model checkpointing configuration. + +```yaml +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:** + +| Parameter | Description | +|---------------|-------------------------------------------------------------------------------| +| `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 + +Hugging Face Hub integration for automatic model uploads. + +```yaml +hub: + push_to_hub: false # Enable Hub uploading + hub_model_id: "username/model-name" # Hub repository ID +``` + +**Key parameters:** + +| Parameter | Description | +|----------------|------------------------------------------------------------------| +| `push_to_hub` | Whether to automatically push trained models to Hugging Face Hub | +| `hub_model_id` | Repository ID in format `"username/repository-name"` | + +### WandbConfig + +Weights & Biases logging configuration. + +```yaml +wandb: + enabled: false # Enable W&B logging + project: "ltx-2-trainer" # W&B project name + entity: null # W&B username or team + tags: [ ] # Tags for the run + log_validation_videos: true # Log validation videos to W&B +``` + +**Key parameters:** + +| Parameter | Description | +|-------------------------|--------------------------------------------------| +| `enabled` | Whether to enable W&B logging | +| `project` | W&B project name | +| `entity` | W&B username or team (null uses default account) | +| `log_validation_videos` | Whether to log validation videos to W&B | + +### FlowMatchingConfig + +Flow matching training configuration for timestep sampling. + +```yaml +flow_matching: + timestep_sampling_mode: "shifted_logit_normal" # Timestep sampling strategy + timestep_sampling_params: { } # Additional sampling parameters +``` + +**Key parameters:** + +| Parameter | Description | +|----------------------------|------------------------------------------------------------| +| `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: + +- Set up your dataset using [Dataset Preparation](dataset-preparation.md) +- Choose your training approach in [Training Modes](training-modes.md) +- Start training with the [Training Guide](training-guide.md) diff --git a/packages/ltx-trainer/docs/custom-training-strategies.md b/packages/ltx-trainer/docs/custom-training-strategies.md new file mode 100644 index 0000000000000000000000000000000000000000..e24d9a03b90f7e9c55f9ab4cc27c13505949137a --- /dev/null +++ b/packages/ltx-trainer/docs/custom-training-strategies.md @@ -0,0 +1,515 @@ +# Implementing Custom Training Strategies + +This guide explains how to implement your own training strategy for specialized recipes that cannot be expressed with +the built-in `flexible` strategy. + +## 📋 Overview + +The trainer uses the **Strategy Pattern** to separate training logic from the core training loop. Each strategy defines: + +1. **What data is needed** - Which preprocessed data directories to load +2. **How to prepare inputs** - Transform batch data into model inputs +3. **How to compute loss** - Calculate the training objective + +This architecture lets you implement new training modes without modifying the core trainer code. + +### 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: + +- **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 + +### How Strategies Fit Into the Trainer + +The trainer delegates all training-mode-specific logic to the strategy: + +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 + - Calls `compute_loss()` to compute the training objective + +The trainer handles everything else: optimization, checkpointing, validation, and distributed training. + +### Key Components + +| Component | Purpose | +|-----------------------------------------------------------------------------------------|--------------------------------------------------------------| +| [`TrainingStrategyConfigBase`](../src/ltx_trainer/training_strategies/base_strategy.py) | Base class for strategy configuration (Pydantic model) | +| [`TrainingStrategy`](../src/ltx_trainer/training_strategies/base_strategy.py) | Abstract base class defining the strategy interface | +| [`ModelInputs`](../src/ltx_trainer/training_strategies/base_strategy.py) | Dataclass containing prepared inputs for the transformer | +| [`Modality`](../../ltx-core/src/ltx_core/model/transformer/modality.py) | ltx-core dataclass representing video or audio modality data | + +## 📝 Step-by-Step Implementation + +### Step 1: Plan Your Strategy + +Before writing code, answer these questions: + +1. **What additional data does your strategy need?** + - 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? + - How should conditioning tokens be structured (e.g., first frame, reference video, mask)? + +3. **How should loss be computed?** + - Which tokens contribute to the loss? + - Are there multiple loss terms to combine? + +### Step 2: Extend Data Preprocessing (If Needed) + +If your strategy requires additional preprocessed data beyond video latents, audio latents, and text embeddings, you'll +need to extend the preprocessing pipeline. + +#### Option A: Modify `process_dataset.py` + +For integrated preprocessing, add new arguments and processing steps to the main script. For example, to add mask +preprocessing: + +```python +# In process_dataset.py, add a new argument +@app.command() +def main( + # ... existing arguments ... + mask_column: str | None = typer.Option( + default=None, + help="Column name containing mask video paths (for inpainting)", + ), +) -> None: + # ... existing processing ... + + # Process masks if provided + if mask_column: + logger.info("Processing mask videos for inpainting training...") + mask_latents_dir = output_base / "mask_latents" + + compute_latents( + dataset_file=dataset_path, + video_column=mask_column, + resolution_buckets=parsed_resolution_buckets, + output_dir=str(mask_latents_dir), + model_path=model_path, + # ... other args ... + ) +``` + +#### Option B: Create a Standalone Script + +For complex preprocessing that doesn't fit naturally into the existing pipeline, create a dedicated script +(e.g., `scripts/process_masks.py`). Use [`scripts/compute_reference.py`](../scripts/compute_reference.py) as a +template - it shows how to process paired data and update the dataset JSON. + +#### Expected Output Structure + +Your preprocessing should create a directory structure that the strategy can reference: + +``` +preprocessed_data_root/ +├── latents/ # Video latents (standard) +├── conditions/ # Text embeddings (standard) +├── audio_latents/ # Audio latents (if with_audio) +├── mask_latents/ # Your custom data directory +└── reference_latents/ # Reference videos (for IC-LoRA) +``` + +### Step 3: Create the Strategy Configuration + +Create a new file for your strategy (e.g., `src/ltx_trainer/training_strategies/inpainting.py`): + +```python +"""Inpainting training strategy. + +This strategy implements video inpainting training where: +- Mask latents indicate which regions to inpaint +- Loss is computed only on masked (inpainted) regions +""" + +from typing import Any, Literal + +import torch +from pydantic import Field +from torch import Tensor + +from ltx_core.model.transformer.modality import Modality +from ltx_trainer.timestep_samplers import TimestepSampler +from ltx_trainer.training_strategies.base_strategy import ( + ModelInputs, + TrainingStrategy, + TrainingStrategyConfigBase, +) + + +class InpaintingConfig(TrainingStrategyConfigBase): + """Configuration for inpainting training strategy.""" + + # The 'name' field acts as a discriminator for the config union + name: Literal["inpainting"] = "inpainting" + + mask_latents_dir: str = Field( + default="mask_latents", + description="Directory name for mask latents", + ) + + # Add any strategy-specific parameters + mask_threshold: float = Field( + default=0.5, + description="Threshold for binary mask conversion", + 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:** + +- 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 + +```python +class InpaintingStrategy(TrainingStrategy): + """Inpainting training strategy. + + Trains the model to fill in masked regions of videos while + keeping unmasked regions as conditioning. + """ + + config: InpaintingConfig + + def __init__(self, config: InpaintingConfig): + super().__init__(config) + + def prepare_training_inputs( + self, + batch: dict[str, Any], + timestep_sampler: TimestepSampler, + ) -> ModelInputs: + """Transform batch data into model inputs. + + This is where the core training logic lives: + 1. Extract and patchify latents + 2. Sample noise and apply it appropriately + 3. Create conditioning masks + 4. Build Modality objects for the transformer + """ + # Get video latents [B, C, F, H, W] + latents_data = batch["latents"] + video_latents = latents_data["latents"] + + # Get dimensions + num_frames = latents_data["num_frames"][0].item() + height = latents_data["height"][0].item() + width = latents_data["width"][0].item() + + # Patchify: [B, C, F, H, W] -> [B, seq_len, C] + video_latents = self._video_patchifier.patchify(video_latents) + + batch_size, seq_len, _ = video_latents.shape + device = video_latents.device + dtype = video_latents.dtype + + # Get mask latents and process them + mask_data = batch["masks"] + mask_latents = mask_data["latents"] + mask_latents = self._video_patchifier.patchify(mask_latents) + + # Create binary mask: True = inpaint this region, False = keep original + inpaint_mask = mask_latents.mean(dim=-1) > self.config.mask_threshold + + # Sample noise and sigmas + sigmas = timestep_sampler.sample_for(video_latents) + noise = torch.randn_like(video_latents) + + # Apply noise only to inpaint regions + sigmas_expanded = sigmas.view(-1, 1, 1) + noisy_latents = (1 - sigmas_expanded) * video_latents + sigmas_expanded * noise + + # Keep original latents for non-inpaint regions (conditioning) + inpaint_mask_expanded = inpaint_mask.unsqueeze(-1) + noisy_latents = torch.where(inpaint_mask_expanded, noisy_latents, video_latents) + + # Create per-token timesteps + # Conditioning tokens (non-inpaint) get timestep=0 + # Inpaint tokens get the sampled sigma + timesteps = self._create_per_token_timesteps(~inpaint_mask, sigmas.squeeze()) + + # Compute targets (velocity prediction: noise - clean) + targets = noise - video_latents + + # Get text embeddings + conditions = batch["conditions"] + video_prompt_embeds = conditions["video_prompt_embeds"] + prompt_attention_mask = conditions["prompt_attention_mask"] + + # Generate position embeddings + positions = self._get_video_positions( + num_frames=num_frames, + height=height, + width=width, + batch_size=batch_size, + fps=24.0, # Or get from latents_data + device=device, + ) + + # Create video Modality + video_modality = Modality( + enabled=True, + latent=noisy_latents, + sigma=sigmas, + timesteps=timesteps, + positions=positions, + context=video_prompt_embeds, + context_mask=prompt_attention_mask, + ) + + # Loss mask: only compute loss on inpaint regions + loss_mask = inpaint_mask + + return ModelInputs( + video=video_modality, + audio=None, + video_targets=targets, + audio_targets=None, + video_loss_mask=loss_mask, + audio_loss_mask=None, + ) + + def compute_loss( + self, + video_pred: Tensor, + audio_pred: Tensor | None, + inputs: ModelInputs, + ) -> Tensor: + """Compute training loss on inpaint regions only. Returns [B,].""" + # MSE loss + loss = (video_pred - inputs.video_targets).pow(2) + + # Apply loss mask and reduce to per-element [B,] + loss_mask = inputs.video_loss_mask.unsqueeze(-1).float() + masked = loss.mul(loss_mask) + return masked.mean(dim=[-2, -1]) / loss_mask.mean(dim=[-2, -1]).clamp(min=1e-8) +``` + +### Step 5: Register the Strategy + +You need to register your strategy in two places: + +**1. Update [`src/ltx_trainer/training_strategies/__init__.py`](../src/ltx_trainer/training_strategies/__init__.py):** + +```python +# Add import for your strategy +from ltx_trainer.training_strategies.inpainting import InpaintingConfig, InpaintingStrategy + +# Add to the TrainingStrategyConfig type alias +TrainingStrategyConfig = TextToVideoConfig | VideoToVideoConfig | FlexibleStrategyConfig | InpaintingConfig + +# Add to __all__ +__all__ = [ + # ... existing exports ... + "InpaintingConfig", + "InpaintingStrategy", +] + + +# Add case in get_training_strategy() +def get_training_strategy(config: TrainingStrategyConfig) -> TrainingStrategy: + match config: + # ... existing cases ... + case InpaintingConfig(): + strategy = InpaintingStrategy(config) +``` + +**2. Update [`src/ltx_trainer/config.py`](../src/ltx_trainer/config.py):** + +```python +# Add import +from ltx_trainer.training_strategies.inpainting import InpaintingConfig + +# Add to the TrainingStrategyConfig union with a Tag matching your strategy name +TrainingStrategyConfig = Annotated[ + Annotated[TextToVideoConfig, Tag("text_to_video")] + | Annotated[VideoToVideoConfig, Tag("video_to_video")] + | Annotated[FlexibleStrategyConfig, Tag("flexible")] + | Annotated[InpaintingConfig, Tag("inpainting")], + Discriminator(_get_strategy_discriminator), +] +``` + +### Step 6: Create a Configuration File + +Create an example config in `configs/`: + +```yaml +# configs/custom_inpainting_lora.yaml + +model: + model_path: "/path/to/ltx2.safetensors" + text_encoder_path: "/path/to/gemma" + training_mode: "lora" + +training_strategy: + name: "inpainting" # Must match your Literal type + mask_latents_dir: "mask_latents" + mask_threshold: 0.5 + +lora: + rank: 32 + alpha: 32 + target_modules: + - "to_k" + - "to_q" + - "to_v" + - "to_out.0" + +data: + preprocessed_data_root: "/path/to/preprocessed/dataset" + +optimization: + learning_rate: 1e-4 + steps: 2000 + batch_size: 1 + +# ... other config sections ... +``` + +## 🔧 Helper Methods Reference + +The base `TrainingStrategy` class provides these helper methods: + +| Method | Purpose | +|----------------------------------------------|-------------------------------------------------| +| `_video_patchifier.patchify(latents)` | Convert `[B, C, F, H, W]` → `[B, seq_len, C]` | +| `_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(conditioning_mask, sampled_sigma)` | Create timesteps with 0 for conditioning tokens | +| `_create_first_frame_conditioning_mask(...)` | Create mask for first-frame conditioning | + +## 📊 Understanding ModelInputs + +The `ModelInputs` dataclass contains everything needed for the forward pass and loss computation: + +```python +@dataclass +class ModelInputs: + video: Modality | None # Video modality data + audio: Modality | None # Audio modality data + + video_targets: Tensor | None # Target values for video loss (velocity) + audio_targets: Tensor | None # Target values for audio loss (velocity) + + video_loss_mask: Tensor | None # Boolean loss mask for video tokens + audio_loss_mask: Tensor | None # Boolean loss mask for audio tokens +``` + +## 📊 Understanding Modality + +The `Modality` dataclass (from ltx-core) represents a single modality's data: + +```python +@dataclass(frozen=True) +class Modality: + 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, sampled_sigma)` to set this up correctly. + +> [!NOTE] +> `Modality` is immutable (frozen dataclass). Use `dataclasses.replace()` to create modified copies. + +## ✅ Testing Your Strategy + +1. **Verify your training configuration is valid:** + ```bash + uv run python -c " + from ltx_trainer.config import LtxTrainerConfig + import yaml + + with open('configs/custom_inpainting_lora.yaml') as f: + config = LtxTrainerConfig(**yaml.safe_load(f)) + print(f'Strategy: {config.training_strategy.name}') + " + ``` + +2. **Test strategy instantiation:** + ```bash + uv run python -c " + from ltx_trainer.training_strategies import get_training_strategy + from ltx_trainer.training_strategies.inpainting import InpaintingConfig + + config = InpaintingConfig() + strategy = get_training_strategy(config) + print(f'Data sources: {config.get_data_sources()}') + " + ``` + +3. **Run a short training test:** + ```bash + uv run python scripts/train.py configs/custom_inpainting_lora.yaml + ``` + +## 💡 Tips and Best Practices + +### Debugging + +- Set `data.num_dataloader_workers: 0` to get clearer error messages +- Use a small dataset and few steps for initial testing +- Check tensor shapes at each step with print statements + +## 🔗 Related Documentation + +- [Training Modes](training-modes.md) - Overview of built-in training modes +- [Configuration Reference](configuration-reference.md) - All configuration options +- [Dataset Preparation](dataset-preparation.md) - Preprocessing workflow +- [ltx-core Documentation](../../ltx-core/README.md) - Core model components + +## 📚 Reference: Existing Strategies + +Study these implementations for guidance: + +| 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) | diff --git a/packages/ltx-trainer/docs/dataset-preparation.md b/packages/ltx-trainer/docs/dataset-preparation.md new file mode 100644 index 0000000000000000000000000000000000000000..6cb0c4023b25c130d92bc9002e83ad9cf7d4f715 --- /dev/null +++ b/packages/ltx-trainer/docs/dataset-preparation.md @@ -0,0 +1,489 @@ +# Dataset Preparation Guide + +This guide covers the complete workflow for preparing and preprocessing your dataset for training. + +## 📋 Overview + +The general dataset preparation workflow is: + +1. **(Optional)** Split long videos into scenes using `split_scenes.py` +2. **(Optional)** Generate captions for your videos using `caption_videos.py` +3. **Preprocess your dataset** using `process_dataset.py` to compute and cache video/audio latents and text embeddings +4. **Run the trainer** with your preprocessed dataset + +## 🎬 Step 1: Split Scenes + +If you're starting with raw, long-form videos (e.g., downloaded from YouTube), you should first split them into shorter, coherent scenes. + +```bash +uv run python scripts/split_scenes.py input.mp4 scenes_output_dir/ \ + --filter-shorter-than 5s +``` + +This will create multiple video clips in `scenes_output_dir`. +These clips will be the input for the captioning step, if you choose to use it. + +The script supports many configuration options for scene detection (detector algorithms, thresholds, minimum scene lengths, etc.): + +```bash +uv run python scripts/split_scenes.py --help +``` + +## 📝 Step 2: Caption Videos + +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 +# Terminal 1: start the captioner server (stays running) +uv run python scripts/serve_captioner.py +``` + +```bash +# Terminal 2: caption your videos +uv run python scripts/caption_videos.py scenes_output_dir/ \ + --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 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:** + +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. +> We recommend reviewing and correcting the generated captions in your `dataset.json` file before proceeding to preprocessing. + +## ⚡ Step 3: Dataset Preprocessing + +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. 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) +> during preprocessing/encoding. +> The simplest fix is to reduce the target resolution (spatially: width/height) and/or the number of frames +> (temporally) by using `--resolution-buckets` with smaller dimensions (lower width/height and/or fewer frames). + +### Basic Usage + +```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 +``` + +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 + +Preprocessing large datasets can take a while. To run it across multiple GPUs in parallel, wrap the command with +`accelerate launch` (for example `--num_processes 4`). Each process handles an interleaved slice of the dataset. +The same approach applies to `process_videos.py` and `process_captions.py` when you run them standalone. + +```bash +uv run accelerate launch --num_processes 4 scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x49" \ + --model-path /path/to/ltx-2-model.safetensors \ + --text-encoder-path /path/to/gemma-model +``` + +Outputs are written atomically (via a per-process temporary file, then renamed), so an interrupted run leaves no +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, +> text encoder, `--lora-trigger`, etc.). Without it the script keeps the stale outputs from the previous run. +> +> ```bash +> uv run accelerate launch --num_processes 4 scripts/process_dataset.py dataset.json \ +> --resolution-buckets "960x544x49" \ +> --model-path /path/to/ltx-2.3-model.safetensors \ +> --text-encoder-path /path/to/gemma-model \ +> --overwrite +> ``` + +### 📊 Dataset Format + +The trainer supports videos, single images, or a mix of both in the same dataset. + +> [!TIP] +> **Image Datasets:** When using images, follow the same preprocessing steps and format requirements as with videos, +> but use `1` for the frame count in the resolution bucket (e.g., `960x544x1`). + +> [!NOTE] +> **Mixed image + video datasets:** Mixing stills and videos in a single dataset is supported, but requires some care: +> +> - Preprocess with **multiple resolution buckets** covering both frame counts — e.g. +> `--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 +> 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 +> you observe instability. +> - 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 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:** + +```json +[ + { + "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" + } +] +``` + +**JSONL format example:** + +```jsonl +{"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,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). +Each video is assigned to the nearest matching bucket. +You can preprocess with one or multiple resolution buckets. +When training with multiple resolution buckets, you must use a batch size of 1. + +The dimensions of each bucket must follow these constraints due to LTX-2's VAE architecture: + +- **Spatial dimensions** (width and height) must be multiples of 32 +- **Number of frames** must satisfy `frames % 8 == 1` (e.g., 1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 121, etc.) + +**Guidelines for choosing training resolution:** + +- For high-quality, detailed videos: use larger spatial dimensions (e.g. 768x448) with fewer frames (e.g. 89) +- For longer, motion-focused videos: use smaller spatial dimensions (512×512) with more frames (121) +- Memory usage increases with both spatial and temporal dimensions + +**Example usage:** + +```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 +``` + +Multiple buckets are supported by separating entries with `;`: + +```bash +uv run python scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x49;512x512x49" \ + --model-path /path/to/ltx-2-model.safetensors \ + --text-encoder-path /path/to/gemma-model +``` + +**Video processing workflow:** + +1. Videos are **resized** maintaining aspect ratio until either width or height matches the target +2. The larger dimension is **center cropped** to match the bucket's dimensions +3. Only the **first X frames are taken** to match the bucket's frame count, remaining frames are ignored + +> [!NOTE] +> The sequence length processed by the transformer model can be calculated as: +> +> ``` +> sequence_length = (H/32) * (W/32) * ((F-1)/8 + 1) +> ``` +> +> Where: +> +> - H = Height of video +> - W = Width of video +> - F = Number of frames +> - 32 = VAE's spatial downsampling factor +> - 8 = VAE's temporal downsampling factor +> +> For example, a 768×448×89 video would have sequence length: +> +> ``` +> (768/32) * (448/32) * ((89-1)/8 + 1) = 24 * 14 * 12 = 4,032 +> ``` +> +> Keep this in mind when choosing video dimensions, as longer sequences require more GPU memory. + +> [!WARNING] +> When training with multiple resolution buckets, you must use a batch size of 1 +> (i.e., set `optimization.batch_size: 1` in your training config). + +### 📁 Output Structure + +The preprocessed data is saved in a `.precomputed` directory: + +``` +dataset/ +└── .precomputed/ + ├── 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. +Reference videos provide the conditioning input while target videos represent the desired transformed output. + +### 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", + "video": "videos/cat_playing.mp4", + "reference_video": "references/cat_playing_depth.mp4" + } +] +``` + +**JSONL format:** + +```jsonl +{"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 + +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-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 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. + +```bash +uv run python scripts/compute_reference.py scenes_output_dir/ \ + --output scenes_output_dir/dataset.json +``` + +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: + +```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 \ + --lora-trigger "MYTRIGGER" +``` + +This acts as a trigger word that activates the LoRA during inference when you include the same token in your prompts. + +> [!NOTE] +> There is no need to manually insert the trigger word into your dataset JSON/JSONL/CSV file. +> The trigger word specified with `--lora-trigger` is automatically prepended to each caption during preprocessing. + +## 🔍 Decoding Videos for Verification + +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 \ + --resolution-buckets "960x544x49" \ + --model-path /path/to/ltx-2-model.safetensors \ + --text-encoder-path /path/to/gemma-model \ + --decode +``` + +For single-frame images, the decoded latents will be saved as PNG files rather than MP4 videos. + +## 🚀 Next Steps + +Once your dataset is preprocessed, you can proceed to: + +- Configure your training parameters in [Configuration Reference](configuration-reference.md) +- Choose your training approach in [Training Modes](training-modes.md) +- Start training with the [Training Guide](training-guide.md) + +> [!TIP] +> 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). diff --git a/packages/ltx-trainer/docs/quick-start.md b/packages/ltx-trainer/docs/quick-start.md new file mode 100644 index 0000000000000000000000000000000000000000..c81953a5f88d9886c272206f6dacbc8703e4485d --- /dev/null +++ b/packages/ltx-trainer/docs/quick-start.md @@ -0,0 +1,160 @@ +# Quick Start Guide + +Get up and running with LTX-2 training in just a few steps! + +## 📋 Prerequisites + +Before you begin, ensure you have: + +1. **LTX-2 Model Checkpoint** - A local `.safetensors` file containing the LTX-2 model weights. + 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; 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/t2v_lora_low_vram.yaml) which enables INT8 quantization and other + memory optimizations + +## ⚡ Installation + +First, install [uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. +Then clone the repository and install the dependencies: + +```bash +git clone https://github.com/Lightricks/LTX-2 +``` + +The `ltx-trainer` package is part of the `LTX-2` monorepo. Install the dependencies from the repository root, +then navigate to the trainer package: + +```bash +# From the repository root +uv sync +cd packages/ltx-trainer +``` + +> [!NOTE] +> The trainer depends on [`ltx-core`](../../ltx-core/) and [`ltx-pipelines`](../../ltx-pipelines/) +> packages which are automatically installed from the monorepo. + +## 🏋 Training Workflow + +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: + +```bash +# Split long videos into scenes (optional) +uv run python scripts/split_scenes.py input.mp4 scenes_output_dir/ --filter-shorter-than 5s + +# Generate captions for videos (optional) +uv run python scripts/caption_videos.py scenes_output_dir/ --output dataset.json + +# Preprocess the dataset (compute latents and embeddings) +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 +``` + +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. + +### 3. Configure Training + +Create or modify a configuration YAML file. Start with one of the example configs: + +- [`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: + +```yaml +model: + model_path: "/path/to/ltx-2-model.safetensors" + text_encoder_path: "/path/to/gemma-model" + +data: + preprocessed_data_root: "/path/to/preprocessed/data" + +output_dir: "outputs/my_training_run" +``` + +See [Configuration Reference](configuration-reference.md) for all available options. + +### 4. Start Training + +```bash +uv run python scripts/train.py configs/t2v_lora.yaml +``` + +For multi-GPU training: + +```bash +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 | 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 of each mode. + +## Next Steps + +Once you've completed your first training run, you can: + +- **Use your trained LoRA for inference** - The [`ltx-pipelines`](../../ltx-pipelines/) package provides + 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) +- Dive deeper into [Training Configuration](configuration-reference.md) +- Understand the model architecture in [LTX-Core Documentation](../../ltx-core/README.md) + +## Need Help? + +If you run into issues at any step, see the [Troubleshooting Guide](troubleshooting.md) for solutions to common +problems. + +Join our [Discord community](https://discord.gg/ltxplatform) for real-time help and discussion! diff --git a/packages/ltx-trainer/docs/training-guide.md b/packages/ltx-trainer/docs/training-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..a61d246bd80b0935b6e51e68ec907989d644a628 --- /dev/null +++ b/packages/ltx-trainer/docs/training-guide.md @@ -0,0 +1,215 @@ +# Training Guide + +This guide covers how to run training jobs, from basic single-GPU training to advanced distributed setups and automatic +model uploads. + +## ⚡ Basic Training (Single GPU) + +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/t2v_lora.yaml +``` + +The trainer will: + +1. **Load your configuration** and validate all parameters +2. **Initialize models** and apply optimizations +3. **Run the training loop** with progress tracking +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:** + +- `checkpoints/lora_weights_step_00000.safetensors` - LoRA checkpoint weights, with the current step in the filename +- `training_config.yaml` - Copy of training configuration +- `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:** + +- `checkpoints/model_weights_step_00000.safetensors` - Full model checkpoint weights, with the current step in the filename +- `training_config.yaml` - Copy of training configuration +- `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 + +We use Hugging Face 🤗 [Accelerate](https://huggingface.co/docs/accelerate/index) for multi-GPU DDP and FSDP. + +### Configure Accelerate + +Run the interactive wizard once to set up your environment (DDP / FSDP, GPU count, etc.): + +```bash +uv run accelerate config +``` + +This stores your preferences in `~/.cache/huggingface/accelerate/default_config.yaml`. + +### Use the Provided Accelerate Configs (Recommended) + +We include ready-to-use Accelerate config files in `configs/accelerate/`: + +- [ddp.yaml](../configs/accelerate/ddp.yaml) — Standard DDP +- [ddp_compile.yaml](../configs/accelerate/ddp_compile.yaml) — DDP with `torch.compile` (Inductor) +- [fsdp.yaml](../configs/accelerate/fsdp.yaml) — Standard FSDP (auto-wraps `BasicAVTransformerBlock`) +- [fsdp_compile.yaml](../configs/accelerate/fsdp_compile.yaml) — FSDP with `torch.compile` (Inductor) + +Launch with a specific config using `--config_file`: + +```bash +# 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/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/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/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/t2v_lora.yaml +``` + +**Notes:** + +- The number of processes is taken from the Accelerate config (`num_processes`). Override with `--num_processes X` or + restrict GPUs with `CUDA_VISIBLE_DEVICES`. +- The compile variants enable `torch.compile` with the Inductor backend via Accelerate's `dynamo_config`. +- FSDP configs auto-wrap the transformer blocks (`fsdp_transformer_layer_cls_to_wrap: BasicAVTransformerBlock`). + +### Launch with Your Default Accelerate Config + +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/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/t2v_lora.yaml + +# Select specific GPUs +CUDA_VISIBLE_DEVICES=0,1 uv run accelerate launch scripts/train.py configs/t2v_lora.yaml +``` + +> [!TIP] +> You can disable the in-terminal progress bars with `--disable-progress-bars` flag in the trainer CLI if desired. + +### Benefits of Distributed Training + +- **Faster training**: Distribute workload across multiple GPUs +- **Larger effective batch sizes**: Combine gradients from multiple GPUs +- **Memory efficiency**: Each GPU handles a portion of the batch + +> [!NOTE] +> Distributed training requires that all GPUs have sufficient memory for the model and batch size. The effective batch +> size becomes `batch_size × num_processes`. + +## 🤗 Pushing Models to Hugging Face Hub + +You can automatically push your trained models to the Hugging Face Hub by adding the following to your configuration: + +```yaml +hub: + push_to_hub: true + hub_model_id: "your-username/your-model-name" +``` + +### Prerequisites + +Before pushing, make sure you: + +1. **Have a Hugging Face account** - Sign up at [huggingface.co](https://huggingface.co) +2. **Are logged in** via `huggingface-cli login` or have set the `HUGGING_FACE_HUB_TOKEN` environment variable +3. **Have write access** to the specified repository (it will be created if it doesn't exist) + +### Login Options + +**Option 1: Interactive login** + +```bash +uv run huggingface-cli login +``` + +**Option 2: Environment variable** + +```bash +export HUGGING_FACE_HUB_TOKEN="your_token_here" +``` + +### What Gets Uploaded + +The trainer will automatically: + +- **Create a model card** with training details and sample outputs +- **Upload model weights** +- **Push sample videos as GIFs** in the model card +- **Include training configuration and prompts** + +## 📊 Weights & Biases Logging + +Enable experiment tracking with W&B by adding to your configuration: + +```yaml +wandb: + enabled: true + project: "ltx-2-trainer" + entity: null # Your W&B username or team + tags: [ "ltx2", "lora" ] + log_validation_videos: true +``` + +This will log: + +- Training loss and learning rate +- Validation videos +- Model configuration +- Training progress + +## 🚀 Next Steps + +After training completes: + +- **Run inference with your trained LoRA** - The [`ltx-pipelines`](../../ltx-pipelines/) package provides + production-ready inference + pipelines that support loading custom LoRAs. Available pipelines include text-to-video, image-to-video, + IC-LoRA video-to-video, and more. See the [`ltx-pipelines`](../../ltx-pipelines/) package for usage details. +- **Test your model** with validation prompts +- **Iterate and improve** based on validation results +- **Share your results** by pushing to Hugging Face Hub + +## 💡 Tips for Successful Training + +- **Start small**: Begin with a small dataset and a few hundred steps to verify everything works +- **Monitor validation**: Keep an eye on validation samples to catch overfitting +- **Adjust learning rate**: Lower learning rates often produce better results +- **Use gradient checkpointing**: Essential for training with limited GPU memory +- **Save checkpoints**: Regular checkpoints help recover from interruptions + +## Need Help? + +If you encounter issues during training, see the [Troubleshooting Guide](troubleshooting.md). + +Join our [Discord community](https://discord.gg/ltxplatform) for real-time help! diff --git a/packages/ltx-trainer/docs/training-modes.md b/packages/ltx-trainer/docs/training-modes.md new file mode 100644 index 0000000000000000000000000000000000000000..2469c77e7cbf93c226f849bd54263c9e6ff6b8a9 --- /dev/null +++ b/packages/ltx-trainer/docs/training-modes.md @@ -0,0 +1,600 @@ +# Training Modes Guide + +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. + +## Key Concepts + +Before diving into individual modes, here are the core ideas behind the flexible strategy: + +- **`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`. + +> [!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 +training_strategy: + name: "flexible" + video: + is_generated: true + latents_dir: "latents" + audio: + is_generated: true + latents_dir: "audio_latents" +``` + +**Example config:** 📄 [t2v_lora.yaml](../configs/t2v_lora.yaml) + +--- + +## 🖼️ Image-to-Video (I2V) + +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 +training_strategy: + 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 config:** 📄 [i2v_lora.yaml](../configs/i2v_lora.yaml) + +--- + +## ⏩ 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" +``` + +> [!NOTE] +> 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. + +**Example configs:** 📄 [video_extend_lora.yaml](../configs/video_extend_lora.yaml) (forward), 📄 [video_suffix_lora.yaml](../configs/video_suffix_lora.yaml) (backward) + +--- + +## 🔄 IC-LoRA / Video-to-Video (V2V) + +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 +training_strategy: + 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 +``` + +> [!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. + +**Example config:** 📄 [v2v_ic_lora.yaml](../configs/v2v_ic_lora.yaml) + +### Dataset Requirements + +- **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 + +**Dataset structure:** + +``` +preprocessed_data_root/ +├── latents/ # Target video latents +├── conditions/ # Text embeddings +└── reference_latents/ # Reference video latents (conditioning input) +``` + +### Generating Reference Videos + +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/ \ + --output scenes_output_dir/dataset.json +``` + +To compute a different condition (depth maps, pose skeletons, etc.), modify the `compute_reference()` function in the +script. + +> [!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, 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** — shorter sequence lengths +- **Faster inference** — reduced memory usage +- **Same aspect ratio** maintained between reference and target + +Preprocess with the `--reference-downscale-factor` option: + +```bash +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-downscale-factor 2 +``` + +> [!NOTE] +> The `reference_video` column is auto-detected by convention — no `--reference-column` flag needed. + +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: + 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 values are 1 (no scaling), 2 (half resolution), or 4 (quarter resolution). + +--- + +## 🔊 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 + +After training, use the [`ltx-pipelines`](../../ltx-pipelines/) package for production inference with your trained +LoRAs: + +| Training Mode | Recommended Pipeline | +|-------------------------|-------------------------------------------------------| +| 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. + +> [!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 + +Once you've chosen your training mode: + +- Set up your dataset using [Dataset Preparation](dataset-preparation.md) +- Configure your training parameters in [Configuration Reference](configuration-reference.md) +- Start training with the [Training Guide](training-guide.md) + +> [!TIP] +> Need a training mode that's not covered here? +> 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. diff --git a/packages/ltx-trainer/docs/troubleshooting.md b/packages/ltx-trainer/docs/troubleshooting.md new file mode 100644 index 0000000000000000000000000000000000000000..ddf8c4ae4b4cf1578f5c7caefa14037b52650828 --- /dev/null +++ b/packages/ltx-trainer/docs/troubleshooting.md @@ -0,0 +1,314 @@ +# Troubleshooting Guide + +This guide covers common issues and solutions when training with the LTX-2 trainer. + +## 🔧 VRAM and Memory Issues + +Memory management is crucial for successful training with LTX-2. + +> [!TIP] +> For GPUs with 32GB VRAM, use the pre-configured low VRAM config: +> [`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 + +#### 1. Enable Gradient Checkpointing + +Gradient checkpointing trades training speed for memory savings. **Highly recommended** for most training runs: + +```yaml +optimization: + enable_gradient_checkpointing: true +``` + +#### 2. Enable 8-bit Text Encoder + +Load the Gemma text encoder in 8-bit precision to save GPU memory: + +```yaml +acceleration: + load_text_encoder_in_8bit: true +``` + +#### 3. Reduce Batch Size + +Lower the batch size if you encounter out-of-memory errors: + +```yaml +optimization: + batch_size: 1 # Start with 1 and increase gradually +``` + +Use gradient accumulation to maintain a larger effective batch size: + +```yaml +optimization: + batch_size: 1 + gradient_accumulation_steps: 4 # Effective batch size = 4 +``` + +#### 4. Use Lower Resolution + +Reduce spatial or temporal dimensions to save memory: + +```bash +# Smaller spatial resolution +uv run python scripts/process_dataset.py dataset.json \ + --resolution-buckets "512x512x49" \ + --model-path /path/to/model.safetensors \ + --text-encoder-path /path/to/gemma + +# Fewer frames +uv run python scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x25" \ + --model-path /path/to/model.safetensors \ + --text-encoder-path /path/to/gemma +``` + +#### 5. Enable Model Quantization + +Use quantization to reduce memory usage: + +```yaml +acceleration: + quantization: "int8-quanto" # Options: int8-quanto, int4-quanto, fp8-quanto +``` + +#### 6. Use 8-bit Optimizer + +The 8-bit AdamW optimizer uses less memory: + +```yaml +optimization: + optimizer_type: "adamw8bit" +``` + +#### 7. Offload Optimizer State During Validation + +If you OOM specifically during validation video sampling — typically in +full fine-tunes or high-rank LoRA runs where AdamW state and the VAE decoder +can't coexist on the GPU — offload optimizer state to CPU during sampling: + +```yaml +acceleration: + offload_optimizer_during_validation: true +``` + +The offload + reload happens once per validation interval, not per step. +No effect for FSDP (sharded state). + +--- + +## ⚠️ Common Usage Issues + +### Issue: "No module named 'ltx_trainer'" Error + +**Solution:** +Ensure you've installed the dependencies and are using `uv run` to execute scripts: + +```bash +# From the repository root +uv sync +cd packages/ltx-trainer +uv run python scripts/train.py configs/t2v_lora.yaml +``` + +> [!TIP] +> Always use `uv run` to execute Python scripts. This automatically uses the correct virtual environment +> without requiring manual activation. + +### Issue: "Gemma model path is not a directory" Error + +**Solution:** +The `text_encoder_path` must point to a directory containing the Gemma model, not a file: + +```yaml +model: + model_path: "/path/to/ltx-2-model.safetensors" # File path + text_encoder_path: "/path/to/gemma-model/" # Directory path +``` + +### Issue: "Model path does not exist" Error + +**Solution:** +LTX-2 requires local model paths. URLs are not supported: + +```yaml +# ✅ Correct - local path +model: + model_path: "/path/to/ltx-2-model.safetensors" + +# ❌ Wrong - URL not supported +model: + model_path: "https://huggingface.co/..." +``` + +### Issue: "Frames must satisfy frames % 8 == 1" Error + +**Solution:** +LTX-2 requires the number of frames to satisfy `frames % 8 == 1`: + +- ✅ Valid: 1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 121 +- ❌ Invalid: 24, 32, 48, 64, 100 + +### Issue: Slow Training Speed + +**Optimizations:** + +1. **Disable gradient checkpointing** (if you have enough VRAM): + + ```yaml + optimization: + enable_gradient_checkpointing: false + ``` + + +2. **Use torch.compile** via Accelerate: + + ```bash + uv run accelerate launch --config_file configs/accelerate/ddp_compile.yaml \ + scripts/train.py configs/t2v_lora.yaml + ``` + +### Issue: Poor Quality Validation Outputs + +**Solutions:** + +1. **Use conditioned validation:** For more reliable validation, use image-to-video (first-frame conditioning) rather than pure text-to-video: + + ```yaml + validation: + 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: 30 + ``` + +3. **Adjust guidance settings:** + + ```yaml + validation: + guidance_scale: 4.0 # CFG scale (recommended: 4.0) + stg_scale: 1.0 # STG scale for temporal coherence (recommended: 1.0) + stg_blocks: [29] # Transformer block to perturb + ``` + +4. **Check caption quality:** + Review and manually edit captions for accuracy if using auto-generated captions. + LTX-2 prefers long, detailed captions that describe both visual content and audio (e.g., ambient sounds, speech, + music). + +5. **Check target modules:** + Ensure your `target_modules` configuration matches your training goals. For audio-video training, + use patterns that match both branches (e.g., `"to_k"` instead of `"attn1.to_k"`). + See [Understanding Target Modules](configuration-reference.md#understanding-target-modules) for details. + +6. **Adjust LoRA rank:** + Try higher values for more capacity: + + ```yaml + lora: + rank: 64 # Or 128 for more capacity + ``` + +7. **Increase training steps:** + + ```yaml + optimization: + steps: 3000 + ``` + +--- + +## 🔍 Debugging Tools + +### Monitor GPU Memory Usage + +Track memory usage during training: + +```bash +# Watch GPU memory in real-time +watch -n 1 nvidia-smi + +# Log memory usage to file +nvidia-smi --query-gpu=memory.used,memory.total --format=csv --loop=5 > memory_log.csv +``` + +### Verify Preprocessed Data + +Decode latents to visualize the preprocessed videos: + +```bash +uv run python scripts/decode_latents.py dataset/.precomputed/latents debug_output \ + --model-path /path/to/model.safetensors +``` + +To also decode audio latents, add the `--with-audio` flag: + +```bash +uv run python scripts/decode_latents.py dataset/.precomputed/latents debug_output \ + --model-path /path/to/model.safetensors \ + --with-audio +``` + +Compare decoded videos and audio with originals to ensure quality. + +--- + +## 💡 Best Practices + +### Before Training + +- [ ] Test preprocessing with a small subset first +- [ ] Verify all video files are accessible +- [ ] Check available GPU memory +- [ ] Review configuration against hardware capabilities +- [ ] Ensure model and text encoder paths are correct + +### During Training + +- [ ] Monitor GPU memory usage +- [ ] Check loss convergence regularly +- [ ] Review validation samples periodically +- [ ] Save checkpoints frequently + +### After Training + +- [ ] Test trained model with diverse prompts +- [ ] Document training parameters and results +- [ ] Archive training data and configs + +## 🆘 Getting Help + +If you're still experiencing issues: + +1. **Check logs:** Review console output for error details +2. **Search issues:** Look through GitHub issues for similar problems +3. **Provide details:** When reporting issues, include: + - Hardware specifications (GPU model, VRAM) + - Configuration file used + - Complete error message + - Steps to reproduce the issue + +--- + +## 🤝 Join the Community + +Have questions, want to share your results, or need real-time help? +Join our [community Discord server](https://discord.gg/ltxplatform) +to connect with other users and the development team! + +- Get troubleshooting help +- Share your training results and workflows +- Stay up to date with announcements and updates + +We look forward to seeing you there! diff --git a/packages/ltx-trainer/docs/utility-scripts.md b/packages/ltx-trainer/docs/utility-scripts.md new file mode 100644 index 0000000000000000000000000000000000000000..3364a83dd2d58a05eb6ff2a04ba06fab784348a1 --- /dev/null +++ b/packages/ltx-trainer/docs/utility-scripts.md @@ -0,0 +1,239 @@ +# Utility Scripts Reference + +This guide covers the various utility scripts available for preprocessing, conversion, and debugging tasks. + +## 🎬 Dataset Processing Scripts + +### Video Scene Splitting + +The `scripts/split_scenes.py` script automatically splits long videos into shorter, coherent scenes. + +```bash +# Basic scene splitting +uv run python scripts/split_scenes.py input.mp4 output_dir/ --filter-shorter-than 5s +``` + +**Key features:** + +- **Automatic scene detection**: Uses PySceneDetect for intelligent splitting +- **Multiple algorithms**: Content-based, adaptive, threshold, and histogram detection +- **Filtering options**: Remove scenes shorter than specified duration +- **Customizable parameters**: Thresholds, window sizes, and detection modes + +**Common options:** + +```bash +# See all available options +uv run python scripts/split_scenes.py --help + +# Use adaptive detection with custom threshold +uv run python scripts/split_scenes.py video.mp4 scenes/ --detector adaptive --threshold 30.0 + +# Limit to maximum number of scenes +uv run python scripts/split_scenes.py video.mp4 scenes/ --max-scenes 50 +``` + +### Automatic Video Captioning + +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 +# Terminal 1 - stays running +uv run python packages/ltx-trainer/scripts/serve_captioner.py + +# 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 +``` + +**Step 2 — caption your videos.** + +```bash +# Terminal 2 - default backend talks to the server above +uv run python packages/ltx-trainer/scripts/caption_videos.py videos_dir/ --output dataset.json + +# 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 +``` + +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. + +Qwen3-Omni-Thinking can optionally emit a `...` 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). + +For Gemini, keep `--num-workers` at 3-5 (higher values may hit API rate limits). + +### Dataset Preprocessing + +The `scripts/process_dataset.py` script processes videos and caches latents for training. + +```bash +# Basic preprocessing +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 video decoding for verification +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 \ + --decode +``` + +Multiple resolution buckets can be specified, separated by `;`: + +```bash +uv run python scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x49;512x512x81" \ + --model-path /path/to/ltx-2-model.safetensors \ + --text-encoder-path /path/to/gemma-model +``` + +> [!NOTE] +> When training with multiple resolution buckets, set `optimization.batch_size: 1`. + +**Multi-GPU preprocessing.** Launch with `accelerate launch` to shard the dataset across processes. Reruns resume +by default (existing `.pt` outputs are skipped); writes are atomic so interrupted runs are safe. Pass `--overwrite` +when rerunning with changed parameters (different model, resolution buckets, text encoder, `--lora-trigger`, etc.) +so stale outputs are replaced. Use the same `accelerate launch` pattern (and `--overwrite` when needed) with +`process_videos.py` or `process_captions.py` when you run those scripts standalone. + +```bash +# Multi-GPU preprocessing +uv run accelerate launch --num_processes 4 scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x49" \ + --model-path /path/to/ltx-2-model.safetensors \ + --text-encoder-path /path/to/gemma-model + +# Force re-encoding of all items (e.g. after switching model or resolution) +uv run accelerate launch --num_processes 4 scripts/process_dataset.py dataset.json \ + --resolution-buckets "960x544x49" \ + --model-path /path/to/ltx-2.3-model.safetensors \ + --text-encoder-path /path/to/gemma-model \ + --overwrite +``` + +For detailed usage, see the [Dataset Preparation Guide](dataset-preparation.md). + +### Reference Video Generation + +The `scripts/compute_reference.py` script provides a template for creating reference videos needed for IC-LoRA training. +The default implementation generates Canny edge reference videos. + +```bash +# Generate Canny edge reference videos +uv run python scripts/compute_reference.py videos_dir/ --output dataset.json +``` + +**Key features:** + +- **Canny edge detection**: Creates edge-based reference videos +- **In-place editing**: Updates existing dataset JSON files +- **Customizable**: Modify the `compute_reference()` function for different conditions (depth, pose, etc.) + +> [!TIP] +> 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 + +The `scripts/decode_latents.py` script decodes precomputed video latents back into video files for visual inspection. + +```bash +# Basic usage +uv run python scripts/decode_latents.py /path/to/latents/dir \ + --output-dir /path/to/output \ + --model-path /path/to/ltx-2-model.safetensors + +# With VAE tiling for large videos +uv run python scripts/decode_latents.py /path/to/latents/dir \ + --output-dir /path/to/output \ + --model-path /path/to/ltx-2-model.safetensors \ + --vae-tiling + +# Decode both video and audio latents +uv run python scripts/decode_latents.py /path/to/latents/dir \ + --output-dir /path/to/output \ + --model-path /path/to/ltx-2-model.safetensors \ + --with-audio +``` + +**The script will:** + +1. **Load the VAE model** from the specified path +2. **Process all `.pt` latent files** in the input directory +3. **Decode each latent** back into a video using the VAE +4. **Save resulting videos** as MP4 files in the output directory + +**When to use:** + +- **Verify preprocessing quality**: Check that your videos were encoded correctly +- **Debug training data**: Visualize what the model actually sees during training +- **Quality assessment**: Ensure latent encoding preserves important visual details + +### Inference with Trained Models + +For inference with trained LoRAs, use the [`ltx-pipelines`](../../ltx-pipelines/) package which provides +production-ready pipelines: + +- **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. + +## 🚀 Training Scripts + +### Basic and Distributed Training + +Use `scripts/train.py` for both single GPU and multi-GPU runs: + +```bash +# Single-GPU training +uv run python scripts/train.py configs/t2v_lora.yaml + +# Multi-GPU (uses your accelerate config) +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/t2v_lora.yaml +``` + +For detailed usage, see the [Training Guide](training-guide.md). + +## 💡 Tips for Using Utility Scripts + +- **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**: 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 diff --git a/packages/ltx-trainer/pyproject.toml b/packages/ltx-trainer/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..ce956d61f6dafa2c134f2769449261b157990f8f --- /dev/null +++ b/packages/ltx-trainer/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "ltx-trainer" +version = "1.1.7" +description = "LTX-2 training, democratized." +readme = "README.md" +authors = [ + { name = "Matan Ben-Yosef", email = "mbyosef@lightricks.com" } +] +requires-python = ">=3.10" +dependencies = [ + "ltx-core", + "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", + "peft>=0.14.0", + "pillow-heif>=0.21.0", + "pydantic>=2.10.4", + "rich>=13.9.4", + "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 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.27.0", + "setuptools>=79.0.0", +] + +[dependency-groups] +dev = [ + "pre-commit>=4.0.1", + "ruff>=0.8.6", +] + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + + +[tool.ruff] +target-version = "1.1.7" +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 = [ + "E", # pycodestyle + "F", # pyflakes + "W", # pycodestyle (warnings) + "I", # isort + "N", # pep8-naming + "ANN", # flake8-annotations + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "EXE", # flake8-executable + "PIE", # flake8-pie + "T20", # flake8-print + "PT", # flake8-pytest + "SIM", # flake8-simplify + "ARG", # flake8-unused-arguments + "PTH", # flake8--use-pathlib + "ERA", # flake8-eradicate + "RUF", # ruff specific rules + "PL", # pylint +] +ignore = [ + "ANN002", # Missing type annotation for *args + "ANN003", # Missing type annotation for **kwargs + "ANN204", # Missing type annotation for special method + "COM812", # Missing trailing comma + "PTH123", # `open()` should be replaced by `Path.open()` + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable +] +[tool.ruff.lint.pylint] +max-args = 10 +[tool.ruff.lint.isort] +known-first-party = ["ltx_trainer", "ltx_core", "ltx_pipelines"] diff --git a/packages/ltx-trainer/scripts/caption_videos.py b/packages/ltx-trainer/scripts/caption_videos.py new file mode 100644 index 0000000000000000000000000000000000000000..ed3fa93d19d7454b5f968435dfbf0a98f71902b9 --- /dev/null +++ b/packages/ltx-trainer/scripts/caption_videos.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 + +""" +Auto-caption videos with audio using multimodal models. +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: + # 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 3.5 (cloud, requires GEMINI_API_KEY) + caption_videos.py videos_dir/ --captioner-type gemini_flash + # Gemini with parallel workers + caption_videos.py videos_dir/ --captioner-type gemini_flash --num-workers 5 + # 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 +import json +from concurrent.futures import ThreadPoolExecutor, as_completed +from enum import Enum +from pathlib import Path + +import typer +from rich.console import Console +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) + +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"] +MEDIA_EXTENSIONS = VIDEO_EXTENSIONS + IMAGE_EXTENSIONS +SAVE_INTERVAL = 5 + +console = Console() +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Auto-caption videos with audio using multimodal models.", +) + + +class OutputFormat(str, Enum): + """Available output formats for captions.""" + + TXT = "txt" # Separate files for captions and video paths, one caption / video path per line + CSV = "csv" # CSV file with video path and caption columns + JSON = "json" # JSON file with video paths as keys and captions as values + JSONL = "jsonl" # JSON Lines file with one JSON object per line + + +def caption_media( + input_path: Path, + output_path: Path, + captioner: MediaCaptioningModel, + extensions: list[str], + recursive: bool, + fps: int, + output_format: OutputFormat, + override: bool, + num_workers: int = 1, +) -> None: + """Caption videos and images using the provided captioning model. + Args: + input_path: Path to input video file or directory + output_path: Path to output caption file + captioner: Media captioning model + 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) + 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) + """ + + # Get list of media files to process + media_files = _get_media_files(input_path, extensions, recursive) + + if not media_files: + console.print("[bold yellow]No media files found to process.[/]") + return + + console.print(f"Found [bold]{len(media_files)}[/] media files to process.") + + # Load existing captions and determine which files need processing + base_dir = output_path.parent.resolve() + existing_captions = _load_existing_captions(output_path, output_format) + existing_abs_paths = {str((base_dir / p).resolve()) for p in existing_captions} + + if override: + media_to_process = media_files + else: + media_to_process = [f for f in media_files if str(f.resolve()) not in existing_abs_paths] + if skipped := len(media_files) - len(media_to_process): + console.print(f"[bold yellow]Skipping {skipped} media that already have captions.[/]") + + if not media_to_process: + console.print("[bold yellow]All media already have captions. Use --override to recaption.[/]") + return + + if num_workers > 1: + console.print(f"Running with [bold cyan]{num_workers}[/] parallel workers.") + + captions = existing_captions.copy() + successfully_captioned = 0 + completed_since_save = 0 + + progress = Progress( + SpinnerColumn(), + TextColumn("{task.description}"), + BarColumn(bar_width=40), + MofNCompleteColumn(), + TimeElapsedColumn(), + TextColumn("•"), + TimeRemainingColumn(), + console=console, + ) + + def process_one(media_file: Path) -> tuple[str, str]: + """Caption a single media file and return (relative_path, caption).""" + caption = captioner.caption( + path=media_file, + fps=fps, + ) + # 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: + task = progress.add_task( + f"Captioning (workers: {num_workers})" if num_workers > 1 else "Captioning", + total=len(media_to_process), + ) + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(process_one, f): f for f in media_to_process} + + for future in as_completed(futures): + media_file = futures[future] + progress.update(task, description=f"Captioning [bold blue]{media_file.name}[/]") + + try: + rel_path, caption = future.result() + + captions[rel_path] = caption + successfully_captioned += 1 + completed_since_save += 1 + + if completed_since_save >= SAVE_INTERVAL: + _save_captions(captions, output_path, output_format) + completed_since_save = 0 + + except Exception as e: + console.print(f"[bold red]Error captioning {media_file.name}: {e}[/]") + + progress.advance(task) + + # Final save with everything accumulated + _save_captions(captions, output_path, output_format) + + # Print summary + console.print( + f"[bold green]✓[/] Captioned [bold]{successfully_captioned}/{len(media_to_process)}[/] media successfully.", + ) + + +def _get_media_files( + input_path: Path, + extensions: list[str] = MEDIA_EXTENSIONS, + recursive: bool = False, +) -> list[Path]: + """Get all media files from the input path.""" + input_path = Path(input_path) + # Normalize extensions to lowercase without dots + extensions_set = {ext.lower().lstrip(".") for ext in extensions} + + if input_path.is_file(): + # If input is a file, check if it has a valid extension + if input_path.suffix.lstrip(".").lower() in extensions_set: + return [input_path] + else: + typer.echo(f"Warning: {input_path} is not a recognized media file. Skipping.") + return [] + elif input_path.is_dir(): + # Find all files and filter by extension case-insensitively + glob_pattern = "**/*" if recursive else "*" + media_files = [ + f for f in input_path.glob(glob_pattern) if f.is_file() and f.suffix.lstrip(".").lower() in extensions_set + ] + return sorted(media_files) + else: + typer.echo(f"Error: {input_path} does not exist.") + raise typer.Exit(code=1) + + +def _save_captions( + captions: dict[str, str], + output_path: Path, + format_type: OutputFormat, +) -> None: + """Save captions to a file in the specified format. + Args: + captions: Dictionary mapping media paths to captions + output_path: Path to save the output file + format_type: Format to save the captions in + """ + # Create parent directories if they don't exist + output_path.parent.mkdir(parents=True, exist_ok=True) + + console.print("[bold blue]Saving captions...[/]") + + match format_type: + case OutputFormat.TXT: + # Create two separate files for captions and media paths + captions_file = output_path.with_stem(f"{output_path.stem}_captions") + paths_file = output_path.with_stem(f"{output_path.stem}_paths") + + with captions_file.open("w", encoding="utf-8") as f: + for caption in captions.values(): + f.write(f"{caption}\n") + + with paths_file.open("w", encoding="utf-8") as f: + for media_path in captions: + f.write(f"{media_path}\n") + + console.print(f"[bold green]✓[/] Captions saved to [cyan]{captions_file}[/]") + console.print(f"[bold green]✓[/] Media paths saved to [cyan]{paths_file}[/]") + + case OutputFormat.CSV: + with output_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["caption", "media_path"]) + for media_path, caption in captions.items(): + writer.writerow([caption, media_path]) + + console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") + + case OutputFormat.JSON: + # Format as list of dictionaries with caption and media_path keys + json_data = [{"caption": caption, "media_path": media_path} for media_path, caption in captions.items()] + + with output_path.open("w", encoding="utf-8") as f: + json.dump(json_data, f, indent=2, ensure_ascii=False) + + console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") + + case OutputFormat.JSONL: + with output_path.open("w", encoding="utf-8") as f: + for media_path, caption in captions.items(): + f.write(json.dumps({"caption": caption, "media_path": media_path}, ensure_ascii=False) + "\n") + + console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") + + case _: + raise ValueError(f"Unsupported output format: {format_type}") + + +def _load_existing_captions( # noqa: PLR0912 + output_path: Path, + format_type: OutputFormat, +) -> dict[str, str]: + """Load existing captions from a file. + Args: + output_path: Path to the captions file + format_type: Format of the captions file + Returns: + Dictionary mapping media paths to captions, or empty dict if file doesn't exist + """ + if not output_path.exists(): + return {} + + console.print(f"[bold blue]Loading existing captions from [cyan]{output_path}[/]...[/]") + + existing_captions = {} + + try: + match format_type: + case OutputFormat.TXT: + # For TXT format, we have two separate files + captions_file = output_path.with_stem(f"{output_path.stem}_captions") + paths_file = output_path.with_stem(f"{output_path.stem}_paths") + + if captions_file.exists() and paths_file.exists(): + captions = captions_file.read_text(encoding="utf-8").splitlines() + paths = paths_file.read_text(encoding="utf-8").splitlines() + + if len(captions) == len(paths): + existing_captions = dict(zip(paths, captions, strict=False)) + + case OutputFormat.CSV: + with output_path.open("r", encoding="utf-8", newline="") as f: + reader = csv.reader(f) + # Skip header + next(reader, None) + for row in reader: + if len(row) >= 2: + caption, media_path = row[0], row[1] + existing_captions[media_path] = caption + + case OutputFormat.JSON: + with output_path.open("r", encoding="utf-8") as f: + json_data = json.load(f) + for item in json_data: + if "caption" in item and "media_path" in item: + existing_captions[item["media_path"]] = item["caption"] + + case OutputFormat.JSONL: + with output_path.open("r", encoding="utf-8") as f: + for line in f: + item = json.loads(line) + if "caption" in item and "media_path" in item: + existing_captions[item["media_path"]] = item["caption"] + + case _: + raise ValueError(f"Unsupported output format: {format_type}") + + console.print(f"[bold green]✓[/] Loaded [bold]{len(existing_captions)}[/] existing captions") + return existing_captions + + except Exception as e: + console.print(f"[bold yellow]Warning: Could not load existing captions: {e}[/]") + return {} + + +@app.command() +def main( # noqa: PLR0913 + input_path: Path = typer.Argument( # noqa: B008 + ..., + help="Path to input video/image file or directory containing media files", + exists=True, + ), + output: Path | None = typer.Option( # noqa: B008 + None, + "--output", + "-o", + help="Path to output file for captions. Format determined by file extension.", + ), + captioner_type: CaptionerType = typer.Option( # noqa: B008 + CaptionerType.QWEN_OMNI, + "--captioner-type", + "-c", + help="Type of captioner to use. Valid values: 'qwen_omni' (local), 'gemini_flash' (API)", + case_sensitive=False, + ), + 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`." + ), + ), + 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, + "--enable-thinking/--no-thinking", + help=( + "Let Qwen3-Omni produce a ... 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, + "--instruction", + "-i", + help="Custom instruction for the captioning model. If not provided, uses an appropriate default.", + ), + extensions: str = typer.Option( + ",".join(MEDIA_EXTENSIONS), + "--extensions", + "-e", + help="Comma-separated list of media file extensions to process", + ), + recursive: bool = typer.Option( + False, + "--recursive", + "-r", + help="Search for media files in subdirectories recursively", + ), + fps: int = typer.Option( + 2, + "--fps", + "-f", + 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, + "--override", + help="Whether to override existing captions for media", + ), + api_key: str | None = typer.Option( + None, + "--api-key", + envvar=["GOOGLE_API_KEY", "GEMINI_API_KEY"], + help="API key for Gemini Flash (can also use GOOGLE_API_KEY or GEMINI_API_KEY env var)", + ), + num_workers: int = typer.Option( + 1, + "--num-workers", + "-w", + min=1, + max=10, + help=( + "Number of parallel workers for captioning (1-10). " + "Values above 1 are only supported for cloud-based captioners (gemini_flash). " + "Using multiple workers with a local model will raise an error." + ), + ), +) -> None: + """Auto-caption videos with audio using multimodal models. + 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 using the local vLLM server (default) + caption_videos.py videos_dir/ -o captions.json + # 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 with custom instruction + caption_videos.py video.mp4 -o captions.json -i "Describe this video in detail" + """ + + # 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[/]. Use --num-workers 1 (default) " + "for the qwen_omni backend." + ) + raise typer.Exit(code=1) + + # Parse extensions + ext_list = [ext.strip() for ext in extensions.split(",")] + + # Determine output path and format + if output is None: + output_format = OutputFormat.JSON + if input_path.is_file(): # noqa: SIM108 + # Default to a JSON file with the same name as the input media + output = input_path.with_suffix(".dataset.json") + else: + # Default to a JSON file in the input directory + output = input_path / "dataset.json" + else: + # Determine format from file extension + output_format = OutputFormat(Path(output).suffix.lstrip(".").lower()) + + # Ensure output path is absolute + output = Path(output).resolve() + console.print(f"Output will be saved to [bold blue]{output}[/]") + + with console.status("Initializing captioner...", spinner="dots"): + if captioner_type == CaptionerType.QWEN_OMNI: + captioner = create_captioner( + captioner_type=captioner_type, + 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( + captioner_type=captioner_type, + api_key=api_key, + instruction=instruction, + ) + else: + raise ValueError(f"Unsupported captioner type: {captioner_type}") + + console.print(f"[bold green]✓[/] {captioner_type.value} captioner ready") + + # Caption media files + caption_media( + input_path=input_path, + output_path=output, + captioner=captioner, + extensions=ext_list, + recursive=recursive, + fps=fps, + output_format=output_format, + override=override, + num_workers=num_workers, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/compute_reference.py b/packages/ltx-trainer/scripts/compute_reference.py new file mode 100644 index 0000000000000000000000000000000000000000..20f515413e74143330b1e6ea388901f267c61cc2 --- /dev/null +++ b/packages/ltx-trainer/scripts/compute_reference.py @@ -0,0 +1,304 @@ +""" +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_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 +""" + +# Standard library imports +import json +from pathlib import Path +from typing import Any + +# Third-party imports +import cv2 +import torch +import torchvision.transforms.functional as TF # noqa: N812 +import typer +from rich.console import Console +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from transformers.utils.logging import disable_progress_bar + +# Local imports +from ltx_trainer.video_utils import read_video, save_video + +# Initialize console and disable progress bars +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, +) -> torch.Tensor: + """Compute Canny edge detection on a batch of images. + Args: + images: Batch of images tensor of shape [B, C, H, W] + Returns: + Binary edge masks tensor of shape [B, H, W] + """ + # Convert to grayscale if needed + if images.shape[1] == 3: + images = TF.rgb_to_grayscale(images) + + # Ensure images are in [0, 1] range + if images.max() > 1.0: + images = images / 255.0 + + # Compute Canny edges + edge_masks = [] + for image in images: + # Convert to numpy for OpenCV + image_np = (image.squeeze().cpu().numpy() * 255).astype("uint8") + + # Apply Canny edge detection + edges = cv2.Canny( + image_np, + threshold1=100, + threshold2=200, + ) + + # Convert back to tensor + edge_mask = torch.from_numpy(edges).float() + edge_masks.append(edge_mask) + + edges = torch.stack(edge_masks) + edges = torch.stack([edges] * 3, dim=1) # Convert to 3-channel + return edges + + +def _get_meta_data( + output_path: Path, +) -> 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: + Dataset rows with media paths and captions + """ + if not output_path.exists(): + return [] + + console.print(f"[bold blue]Reading meta data from [cyan]{output_path}[/]...[/]") + + try: + with output_path.open("r", encoding="utf-8") as f: + json_data = json.load(f) + return json_data + + except Exception as e: + console.print(f"[bold yellow]Warning: Could not check meta data: {e}[/]") + 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], + output_path: Path, +) -> None: + """Save dataset json with reference video paths. + Args: + reference_paths: Dictionary mapping media paths to reference video paths + output_path: Path to save the output file + """ + + with output_path.open("r", encoding="utf-8") as f: + json_data = json.load(f) + new_json_data = json_data.copy() + for i, item in enumerate(json_data): + media_path = _get_media_path(item) + reference_path = reference_paths[media_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:[/] 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( + input_path: Path, + output_path: Path, + override: bool, + batch_size: int = 100, +) -> None: + """Process videos and images to compute condition on videos. + Args: + input_path: Path to input video/image file or directory + output_path: Path to output reference video file + override: Whether to override existing reference video files + """ + if not output_path.exists(): + raise FileNotFoundError( + f"Output file does not exist: {output_path}. This is also the input file for the dataset." + ) + + # Check for existing reference video files + meta_data = _get_meta_data(output_path) + + base_dir = input_path.resolve() + console.print(f"Using [bold blue]{base_dir}[/] as base directory for relative paths") + + # Filter media files + media_to_process = [] + skipped_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(_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) + + console.print(f"Processing [bold]{len(media_to_process)}[/] media.") + + # Initialize progress tracking + progress = Progress( + SpinnerColumn(), + TextColumn("{task.description}"), + BarColumn(bar_width=40), + MofNCompleteColumn(), + TimeElapsedColumn(), + TextColumn("•"), + TimeRemainingColumn(), + console=console, + ) + + # Process media files + 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, rel_path in zip(media_to_process, media_paths, strict=True): + progress.update(task, description=f"Processing [bold blue]{media_file.name}[/]") + + # 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) + 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: + video, fps = read_video(media_file) + + # Process frames in batches + condition_frames = [] + + for i in range(0, len(video), batch_size): + batch = video[i : i + batch_size] + condition_batch = compute_reference(batch) + condition_frames.append(condition_batch) + + # Concatenate all edge frames + all_condition = torch.cat(condition_frames, dim=0) + + # Save the edge video + save_video(all_condition, reference_path.resolve(), fps=fps) + + except Exception as e: + console.print(f"[bold red]Error processing [bold blue]{media_file}[/]: {e}[/]") + reference_paths.pop(rel_path) + else: + skipped_media.append(media_file) + + progress.advance(task) + + # Save results + _save_dataset_json(reference_paths, output_path) + + # Print summary + total_to_process = len(media_files) - len(skipped_media) + console.print( + f"[bold green]✓[/] Processed [bold]{total_to_process}/{len(media_files)}[/] media successfully.", + ) + + +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Compute reference videos for IC-LoRA training.", +) + + +@app.command() +def main( + input_path: Path = typer.Argument( # noqa: B008 + ..., + help="Path to input video/image file or directory containing media files", + exists=True, + ), + output: Path = typer.Option( # noqa: B008 + ..., + "--output", + "-o", + help="Path to json output file for reference video paths. " + "This is also the input file for the dataset, the output of compute_captions.py.", + ), + override: bool = typer.Option( + False, + "--override", + help="Whether to override existing reference video files", + ), + batch_size: int = typer.Option( + 100, + "--batch-size", + help="Batch size for processing videos", + ), +) -> None: + """Compute reference videos for IC-LoRA training. + This script generates reference videos (e.g., Canny edge maps) for given videos. + The paths in the output file will be relative to the output file's directory. + Examples: + # Process all videos in a directory + compute_reference.py videos_dir/ -o videos_dir/captions.json + """ + + # Ensure output path is absolute + output = Path(output).resolve() + console.print(f"Output will be saved to [bold blue]{output}[/]") + + # Verify output path exists + if not output.exists(): + raise FileNotFoundError(f"Output file does not exist: {output}. This is also the input file for the dataset.") + + # Process media files + process_media( + input_path=input_path, + output_path=output, + override=override, + batch_size=batch_size, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/decode_latents.py b/packages/ltx-trainer/scripts/decode_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..f27ff4fcecfb842adc905a827dcadc3b4ddd2f4a --- /dev/null +++ b/packages/ltx-trainer/scripts/decode_latents.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 + +""" +Decode precomputed video latents back into videos using the VAE. +This script loads latent files saved during preprocessing and decodes them +back into video clips using the same VAE model. +Basic usage: + python scripts/decode_latents.py /path/to/latents/dir /path/to/output \ + --model-source /path/to/ltx2.safetensors +""" + +from pathlib import Path + +import torch +import torchaudio +import torchvision.utils +import typer +from einops import rearrange +from rich.console import Console +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from transformers.utils.logging import disable_progress_bar + +from ltx_core.model.video_vae import SpatialTilingConfig, TemporalTilingConfig, TilingConfig +from ltx_trainer import logger +from ltx_trainer.model_loader import load_audio_vae_decoder, load_video_vae_decoder, load_vocoder +from ltx_trainer.video_utils import save_video + +DEFAULT_TILE_SIZE_PIXELS = 512 # Spatial tile size in pixels (must be ≥64 and divisible by 32) +DEFAULT_TILE_OVERLAP_PIXELS = 128 # Spatial tile overlap in pixels (must be divisible by 32) +DEFAULT_TILE_SIZE_FRAMES = 128 # Temporal tile size in frames (must be ≥16 and divisible by 8) +DEFAULT_TILE_OVERLAP_FRAMES = 24 # Temporal tile overlap in frames (must be divisible by 8) + +disable_progress_bar() +console = Console() +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Decode precomputed video latents back into videos using the VAE.", +) + + +class LatentsDecoder: + def __init__( + self, + model_path: str, + device: str = "cuda", + vae_tiling: bool = False, + with_audio: bool = False, + ): + """Initialize the decoder with model configuration. + Args: + model_path: Path to LTX-2 checkpoint (.safetensors) + device: Device to use for computation + vae_tiling: Whether to enable VAE tiling for larger video resolutions + with_audio: Whether to load audio VAE for audio decoding + """ + self.device = torch.device(device) + self.model_path = model_path + self.vae = None + self.audio_vae = None + self.vocoder = None + self.vae_tiling = vae_tiling + + self._load_model(model_path, with_audio) + + def _load_model(self, model_path: str, with_audio: bool = False) -> None: + """Initialize and load the VAE model(s).""" + with console.status(f"[bold]Loading video VAE decoder from {model_path}...", spinner="dots"): + self.vae = load_video_vae_decoder(model_path, device=self.device, dtype=torch.bfloat16) + + if with_audio: + with console.status(f"[bold]Loading audio VAE decoder from {model_path}...", spinner="dots"): + self.audio_vae = load_audio_vae_decoder(model_path, device=self.device, dtype=torch.bfloat16) + + with console.status(f"[bold]Loading vocoder from {model_path}...", spinner="dots"): + self.vocoder = load_vocoder(model_path, device=self.device) + + @torch.inference_mode() + def decode(self, latents_dir: Path, output_dir: Path, seed: int | None = None) -> None: + """Decode all latent files in the directory recursively. + Args: + latents_dir: Directory containing latent files (.pt) + output_dir: Directory to save decoded videos + seed: Optional random seed for noise generation + """ + # Find all .pt files recursively + latent_files = list(latents_dir.rglob("*.pt")) + + if not latent_files: + logger.warning(f"No .pt files found in {latents_dir}") + return + + logger.info(f"Found {len(latent_files):,} latent files to decode") + + # Process files with progress bar + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task("Decoding latents", total=len(latent_files)) + + for latent_file in latent_files: + # Calculate relative path to maintain directory structure + rel_path = latent_file.relative_to(latents_dir) + output_subdir = output_dir / rel_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + try: + self._process_file(latent_file, output_subdir, seed) + except Exception as e: + logger.error(f"Error processing {latent_file}: {e}") + continue + + progress.advance(task) + + logger.info(f"Decoding complete! Videos saved to {output_dir}") + + @torch.inference_mode() + def decode_audio(self, latents_dir: Path, output_dir: Path) -> None: + """Decode all audio latent files in the directory recursively. + Args: + latents_dir: Directory containing audio latent files (.pt) + output_dir: Directory to save decoded audio files + """ + # Check if audio VAE is loaded + if self.audio_vae is None or self.vocoder is None: + logger.warning("Audio VAE or vocoder not loaded. Skipping audio decoding.") + return + + # Find all .pt files recursively + latent_files = list(latents_dir.rglob("*.pt")) + + if not latent_files: + logger.warning(f"No .pt files found in {latents_dir}") + return + + logger.info(f"Found {len(latent_files):,} audio latent files to decode") + + # Process files with progress bar + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task("Decoding audio latents", total=len(latent_files)) + + for latent_file in latent_files: + # Calculate relative path to maintain directory structure + rel_path = latent_file.relative_to(latents_dir) + output_subdir = output_dir / rel_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + try: + self._process_audio_file(latent_file, output_subdir) + except Exception as e: + logger.error(f"Error processing audio {latent_file}: {e}") + continue + + progress.advance(task) + + logger.info(f"Audio decoding complete! Audio files saved to {output_dir}") + + def _process_file(self, latent_file: Path, output_dir: Path, seed: int | None) -> None: + """Process a single latent file.""" + # Load the latent data + data = torch.load(latent_file, map_location=self.device, weights_only=False) + + # Get latents - handle both old patchified [seq_len, C] and new [C, F, H, W] formats + latents = data["latents"] + num_frames = data["num_frames"] + height = data["height"] + width = data["width"] + + # Check if latents need reshaping (old patchified format) + if latents.dim() == 2: + # Old format: [seq_len, C] -> reshape to [C, F, H, W] + latents = rearrange(latents, "(f h w) c -> c f h w", f=num_frames, h=height, w=width) + + # Add batch dimension: [C, F, H, W] -> [1, C, F, H, W] + latents = latents.unsqueeze(0).to(device=self.device, dtype=torch.bfloat16) + + # Create generator only if seed is provided + generator = None + if seed is not None: + generator = torch.Generator(device=self.device) + generator.manual_seed(seed) + + # Decode the video + video = self._decode_video(latents, generator) + + # Determine output format and save + is_image = video.shape[0] == 1 + if is_image: + # Save as PNG for single frame + output_path = output_dir / f"{latent_file.stem}.png" + torchvision.utils.save_image( + video[0], # [C, H, W] in [0, 1] + str(output_path), + ) + else: + # Save as MP4 for video using PyAV-based save_video + output_path = output_dir / f"{latent_file.stem}.mp4" + fps = data.get("fps", 24) # Use stored FPS or default to 24 + save_video( + video_tensor=video, # [F, C, H, W] in [0, 1] + output_path=output_path, + fps=fps, + ) + + def _decode_video(self, latents: torch.Tensor, generator: torch.Generator | None = None) -> torch.Tensor: + """Decode latents to video frames.""" + if self.vae_tiling: + # Use tiled decoding for reduced VRAM + tiling_config = TilingConfig( + spatial_config=SpatialTilingConfig( + tile_size_in_pixels=DEFAULT_TILE_SIZE_PIXELS, + tile_overlap_in_pixels=DEFAULT_TILE_OVERLAP_PIXELS, + ), + temporal_config=TemporalTilingConfig( + tile_size_in_frames=DEFAULT_TILE_SIZE_FRAMES, + tile_overlap_in_frames=DEFAULT_TILE_OVERLAP_FRAMES, + ), + ) + chunks = list( + self.vae.tiled_decode( + latents, + tiling_config=tiling_config, + generator=generator, + ) + ) + # Concatenate along temporal dimension + video = torch.cat(chunks, dim=2) # [B, C, F, H, W] + else: + # Standard full decoding + video = self.vae(latents, generator=generator) # [B, C, F, H, W] + + # Convert to [F, C, H, W] format and normalize to [0, 1] + video = rearrange(video, "1 c f h w -> f c h w") + video = (video + 1) / 2 # Denormalize from [-1, 1] to [0, 1] + video = video.clamp(0, 1) + + return video + + def _process_audio_file(self, latent_file: Path, output_dir: Path) -> None: + """Process a single audio latent file.""" + # Load the latent data + data = torch.load(latent_file, map_location=self.device, weights_only=False) + + latents = data["latents"].to(device=self.device, dtype=torch.float32) + num_time_steps = data["num_time_steps"] + freq_bins = data["frequency_bins"] + + # Handle both old patchified [seq_len, C] and new [C, T, F] formats + if latents.dim() == 2: + # Old format: [seq_len, channels] where seq_len = time * freq + # Reshape to [C, T, F] + latents = rearrange(latents, "(t f) c -> c t f", t=num_time_steps, f=freq_bins) + + # Add batch dimension: [C, T, F] -> [1, C, T, F] + latents = latents.unsqueeze(0) + + # Set correct dtype for audio VAE + latents = latents.to(dtype=torch.bfloat16) + + # Decode audio using audio VAE decoder (produces mel spectrogram) + mel_spectrogram = self.audio_vae(latents) + + # Convert mel spectrogram to waveform using vocoder + waveform = self.vocoder(mel_spectrogram) + + # Save as WAV + output_path = output_dir / f"{latent_file.stem}.wav" + sample_rate = self.vocoder.output_sampling_rate + torchaudio.save(str(output_path), waveform[0].cpu(), sample_rate) + + +@app.command() +def main( + latents_dir: str = typer.Argument( + ..., + help="Directory containing the precomputed latent files (searched recursively)", + ), + output_dir: str = typer.Argument( + ..., + help="Directory to save the decoded videos (maintains same folder hierarchy as input)", + ), + model_path: str = typer.Option( + ..., + help="Path to LTX-2 checkpoint (.safetensors file)", + ), + device: str = typer.Option( + default="cuda", + help="Device to use for computation", + ), + vae_tiling: bool = typer.Option( + default=True, + help="Enable VAE tiling for larger video resolutions", + ), + seed: int | None = typer.Option( + default=None, + help="Random seed for noise generation during decoding", + ), + with_audio: bool = typer.Option( + default=False, + help="Also decode audio latents (requires audio_latents directory)", + ), + audio_latents_dir: str | None = typer.Option( + default=None, + help="Directory containing audio latent files (defaults to 'audio_latents' sibling of latents_dir)", + ), +) -> None: + """Decode precomputed video latents back into videos using the VAE. + This script recursively searches for .pt latent files in the input directory + and decodes them to videos, maintaining the same folder hierarchy in the output. + Examples: + # Basic usage + python scripts/decode_latents.py /path/to/latents /path/to/videos \\ + --model-path /path/to/ltx2.safetensors + # With VAE tiling for large videos + python scripts/decode_latents.py /path/to/latents /path/to/videos \\ + --model-path /path/to/ltx2.safetensors --vae-tiling + # With audio decoding + python scripts/decode_latents.py /path/to/latents /path/to/videos \\ + --model-path /path/to/ltx2.safetensors --with-audio + """ + latents_path = Path(latents_dir) + output_path = Path(output_dir) + + if not latents_path.exists() or not latents_path.is_dir(): + raise typer.BadParameter(f"Latents directory does not exist: {latents_path}") + + decoder = LatentsDecoder( + model_path=model_path, + device=device, + vae_tiling=vae_tiling, + with_audio=with_audio, + ) + decoder.decode(latents_path, output_path, seed=seed) + + # Decode audio if requested + if with_audio: + audio_path = Path(audio_latents_dir) if audio_latents_dir else latents_path.parent / "audio_latents" + + if audio_path.exists(): + audio_output_path = output_path.parent / "decoded_audio" + decoder.decode_audio(audio_path, audio_output_path) + else: + logger.warning(f"Audio latents directory not found: {audio_path}") + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/process_captions.py b/packages/ltx-trainer/scripts/process_captions.py new file mode 100644 index 0000000000000000000000000000000000000000..037848e93365c07b18d9644b78d70412f48a138a --- /dev/null +++ b/packages/ltx-trainer/scripts/process_captions.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python + +""" +Compute text embeddings for video generation training. +This module provides functionality for processing text captions, including: +- Loading captions from various file formats (CSV, JSON, JSONL) +- Cleaning and preprocessing text (removing LLM prefixes, adding ID tokens) +- CaptionsDataset for caption-only preprocessing workflows +Can be used as a standalone script: + python scripts/process_captions.py dataset.json --output-dir /path/to/output \ + --model-source /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma +""" + +import json +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pandas as pd +import torch +import typer +from accelerate import PartialState +from rich.console import Console +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from torch.utils.data import DataLoader, Dataset, Subset +from transformers.utils.logging import disable_progress_bar + +from ltx_trainer import logger +from ltx_trainer.model_loader import load_embeddings_processor, load_text_encoder + +# Disable tokenizers parallelism to avoid warnings +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +disable_progress_bar() + +# Common phrases that LLMs often add to captions that we might want to remove +COMMON_BEGINNING_PHRASES: tuple[str, ...] = ( + "This video", + "The video", + "This clip", + "The clip", + "The animation", + "This image", + "The image", + "This picture", + "The picture", +) + +COMMON_CONTINUATION_WORDS: tuple[str, ...] = ( + "shows", + "depicts", + "features", + "captures", + "highlights", + "introduces", + "presents", +) + +COMMON_LLM_START_PHRASES: tuple[str, ...] = ( + "In the video,", + "In this video,", + "In this video clip,", + "In the clip,", + "Caption:", + *( + f"{beginning} {continuation}" + for beginning in COMMON_BEGINNING_PHRASES + for continuation in COMMON_CONTINUATION_WORDS + ), +) + +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Process text captions and save embeddings for video generation training.", +) + + +class CaptionsDataset(Dataset): + """ + Dataset for processing text captions only. + This dataset is designed for caption preprocessing workflows where you only need + to process text without loading videos. Useful for: + - Precomputing text embeddings + - Caption cleaning and preprocessing + - Text-only preprocessing pipelines + """ + + def __init__( + self, + dataset_file: str | Path, + caption_column: str, + media_column: str = "media_path", + lora_trigger: str | None = None, + remove_llm_prefixes: bool = False, + ) -> None: + """ + Initialize the captions dataset. + Args: + dataset_file: Path to CSV/JSON/JSONL metadata file + caption_column: Column name for captions in the metadata file + media_column: Column name for media paths (used for output naming) + lora_trigger: Optional trigger word to prepend to each caption + remove_llm_prefixes: Whether to remove common LLM-generated prefixes + """ + super().__init__() + + self.dataset_file = Path(dataset_file) + self.caption_column = caption_column + self.media_column = media_column + self.lora_trigger = f"{lora_trigger.strip()} " if lora_trigger else "" + + # Load captions with their corresponding output embedding paths + self.caption_data = self._load_caption_data() + + # Convert to lists for indexing + self.output_paths = list(self.caption_data.keys()) + self.prompts = list(self.caption_data.values()) + + # Clean LLM start phrases if requested + if remove_llm_prefixes: + self._clean_llm_prefixes() + + def __len__(self) -> int: + return len(self.prompts) + + def __getitem__(self, index: int) -> dict[str, Any]: + """Get a single caption with optional trigger word prepended and output path.""" + prompt = self.lora_trigger + self.prompts[index] + return { + "prompt": prompt, + "output_path": self.output_paths[index], + "index": index, + } + + def _load_caption_data(self) -> dict[str, str]: + """Load captions and compute their output embedding paths.""" + if self.dataset_file.suffix == ".csv": + return self._load_caption_data_from_csv() + elif self.dataset_file.suffix == ".json": + return self._load_caption_data_from_json() + elif self.dataset_file.suffix == ".jsonl": + return self._load_caption_data_from_jsonl() + 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) + + if self.caption_column not in df.columns: + raise ValueError(f"Column '{self.caption_column}' not found in CSV file") + if self.media_column not in df.columns: + raise ValueError(f"Column '{self.media_column}' not found in CSV file") + + caption_data = {} + for _, row in df.iterrows(): + media_path = Path(row[self.media_column].strip()) + output_path = self._embedding_output_path(media_path) + caption_data[output_path] = row[self.caption_column] + + return caption_data + + def _load_caption_data_from_json(self) -> dict[str, str]: + """Load captions from a JSON file and compute output embedding paths.""" + 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") + + caption_data = {} + for entry in data: + if self.caption_column not in entry: + raise ValueError(f"Key '{self.caption_column}' not found in JSON entry: {entry}") + if self.media_column not in entry: + raise ValueError(f"Key '{self.media_column}' not found in JSON entry: {entry}") + + media_path = Path(entry[self.media_column].strip()) + output_path = self._embedding_output_path(media_path) + caption_data[output_path] = entry[self.caption_column] + + return caption_data + + def _load_caption_data_from_jsonl(self) -> dict[str, str]: + """Load captions from a JSONL file and compute output embedding paths.""" + caption_data = {} + with open(self.dataset_file, "r", encoding="utf-8") as file: + for line in file: + entry = json.loads(line) + if self.caption_column not in entry: + raise ValueError(f"Key '{self.caption_column}' not found in JSONL entry: {entry}") + if self.media_column not in entry: + raise ValueError(f"Key '{self.media_column}' not found in JSONL entry: {entry}") + + media_path = Path(entry[self.media_column].strip()) + output_path = self._embedding_output_path(media_path) + caption_data[output_path] = entry[self.caption_column] + + return caption_data + + def _clean_llm_prefixes(self) -> None: + """Remove common LLM-generated prefixes from captions.""" + for i in range(len(self.prompts)): + self.prompts[i] = self.prompts[i].strip() + for phrase in COMMON_LLM_START_PHRASES: + if self.prompts[i].startswith(phrase): + self.prompts[i] = self.prompts[i].removeprefix(phrase).strip() + break + + +def compute_captions_embeddings( # noqa: PLR0913 + dataset_file: str | Path, + output_dir: str, + model_path: str, + text_encoder_path: str, + caption_column: str = "caption", + media_column: str = "media_path", + lora_trigger: str | None = None, + remove_llm_prefixes: bool = False, + batch_size: int = 8, + device: str = "cuda", + load_in_8bit: bool = False, + overwrite: bool = False, +) -> None: + """ + Process captions and save text embeddings. + Under ``accelerate launch``, each process handles an interleaved shard of + the dataset (rank/world read from ``accelerate.PartialState``). Already- + computed ``.pt`` outputs are skipped unless ``overwrite=True``; writes are + atomic so an interrupted run is safe to resume. + Args: + dataset_file: Path to metadata file (CSV/JSON/JSONL) containing captions and media paths + output_dir: Directory to save embeddings + model_path: Path to LTX-2 checkpoint (.safetensors) + text_encoder_path: Path to Gemma text encoder directory + caption_column: Column name containing captions in the metadata file + media_column: Column name containing media paths (used for output naming) + lora_trigger: Optional trigger word to prepend to each caption + remove_llm_prefixes: Whether to remove common LLM-generated prefixes + batch_size: Batch size for processing + device: Device to use for computation + load_in_8bit: Whether to load the Gemma text encoder in 8-bit precision + overwrite: Re-encode every item even if its output exists. Use when rerunning with + changed parameters (different text encoder, lora_trigger, etc.) so stale + outputs are replaced. + """ + console = Console() + + dataset = CaptionsDataset( + dataset_file=dataset_file, + caption_column=caption_column, + media_column=media_column, + lora_trigger=lora_trigger, + remove_llm_prefixes=remove_llm_prefixes, + ) + logger.info(f"Loaded {len(dataset):,} captions") + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # TODO(batch-tokenization): The current Gemma tokenizer doesn't support batched tokenization. + if batch_size > 1: + logger.warning( + "Batch size greater than 1 is not currently supported with the Gemma tokenizer. " + "Overriding batch_size to 1. This will be fixed in a future update." + ) + batch_size = 1 + + dataloader = _build_sharded_dataloader( + dataset, + batch_size=batch_size, + num_workers=2, + is_done=lambda idx: (output_path / dataset.output_paths[idx]).is_file(), + overwrite=overwrite, + ) + if dataloader is None: + return + + # Load text encoder and embeddings processor + with console.status("[bold]Loading Gemma text encoder...", spinner="dots"): + text_encoder = load_text_encoder( + text_encoder_path, + device=device, + dtype=torch.bfloat16, + load_in_8bit=load_in_8bit, + ) + embeddings_processor = load_embeddings_processor( + model_path, + device=device, + dtype=torch.bfloat16, + ) + + logger.info("Text encoder and embeddings processor loaded successfully") + logger.info(f"Processing captions in {len(dataloader):,} batches...") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task("Processing captions", total=len(dataloader)) + for batch in dataloader: + # Encode prompts using text_encoder.encode() + feature_extractor + # (returns video/audio features before connector). + # The connector is applied during training via embeddings_processor + with torch.inference_mode(): + # 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"])): + 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" + ) + + output_rel_path = Path(batch["output_path"][i]) + + # Create output directory maintaining structure + output_dir_path = output_path / output_rel_path.parent + output_dir_path.mkdir(parents=True, exist_ok=True) + + embedding_data = { + "video_prompt_embeds": video_prompt_embeds[0].cpu().contiguous(), + "prompt_attention_mask": prompt_attention_mask[0].cpu().contiguous(), + } + if audio_prompt_embeds is not None: + embedding_data["audio_prompt_embeds"] = audio_prompt_embeds[0].cpu().contiguous() + + output_file = output_path / output_rel_path + _atomic_save(embedding_data, output_file) + + progress.advance(task) + + logger.info(f"Processed {len(dataloader.dataset):,} captions -> {output_path}") # type: ignore[arg-type] + + +def _atomic_save(data: Any, out: Path) -> None: # noqa: ANN401 + """Save to ``out`` atomically via per-PID temp file + replace. + Crash mid-write leaves an orphan ``.tmp.`` file that the skip logic + ignores. The per-PID suffix makes concurrent writes from multiple ranks + collision-free. + """ + tmp = out.with_suffix(f"{out.suffix}.tmp.{os.getpid()}") + torch.save(data, tmp) + tmp.replace(out) + + +def _build_sharded_dataloader( + dataset: Dataset, + *, + batch_size: int, + num_workers: int, + is_done: Callable[[int], bool], + overwrite: bool, +) -> DataLoader | None: + """Return a DataLoader over this rank's interleaved shard of ``dataset``. + When ``overwrite`` is False, items whose outputs already exist (per + ``is_done``) are filtered out. Returns ``None`` if this rank has nothing + to do, so the caller can early-return without loading any models. + """ + state = PartialState() + todo = [i for i in range(state.process_index, len(dataset), state.num_processes) if overwrite or not is_done(i)] + if not todo: + logger.info(f"Rank {state.process_index}/{state.num_processes}: nothing to do") + return None + logger.info(f"Rank {state.process_index}/{state.num_processes}: processing {len(todo):,} of {len(dataset):,} items") + return DataLoader(Subset(dataset, todo), batch_size=batch_size, shuffle=False, num_workers=num_workers) + + +@app.command() +def main( # noqa: PLR0913 + dataset_file: str = typer.Argument( + ..., + help="Path to metadata file (CSV/JSON/JSONL) containing captions and media paths", + ), + output_dir: str = typer.Option( + ..., + help="Output directory to save text embeddings", + ), + model_path: str = typer.Option( + ..., + help="Path to LTX-2 checkpoint (.safetensors file)", + ), + text_encoder_path: str = typer.Option( + ..., + 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", + ), + media_column: str = typer.Option( + default="media_path", + help="Column name in the dataset JSON/JSONL/CSV file containing media paths " + "(used for output file naming and folder structure)", + ), + batch_size: int = typer.Option( + default=8, + help="Batch size for processing", + ), + device: str = typer.Option( + default="cuda", + help="Device to use for computation", + ), + lora_trigger: str | None = typer.Option( + default=None, + help="Optional trigger word to prepend to each caption (activates the LoRA during inference)", + ), + remove_llm_prefixes: bool = typer.Option( + default=False, + help="Remove common LLM-generated prefixes from captions", + ), + 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)", + ), + overwrite: bool = typer.Option( + default=False, + help="Re-encode every caption even if its output exists. Use when rerunning with " + "changed parameters (different text encoder, lora_trigger, etc.) so stale outputs are replaced.", + ), +) -> None: + """Process text captions and save embeddings 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 captions from metadata files and saves text embeddings + that can be used for training video generation models. The output embeddings + will maintain the same folder structure and naming as the corresponding media files. + Note: This script is designed for LTX-2 models which use the Gemma text encoder. + Examples: + # Process captions with LTX-2 model + python scripts/process_captions.py dataset.json --output-dir ./embeddings \\ + --model-path /path/to/ltx2_checkpoint.safetensors \\ + --text-encoder-path /path/to/gemma + # Add a trigger word for LoRA training + python scripts/process_captions.py dataset.json --output-dir ./embeddings \\ + --model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\ + --lora-trigger "mytoken" + # Remove LLM-generated prefixes from captions + python scripts/process_captions.py dataset.json --output-dir ./embeddings \\ + --model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\ + --remove-llm-prefixes + """ + + # Validate dataset file + if not Path(dataset_file).is_file(): + raise typer.BadParameter(f"Dataset file not found: {dataset_file}") + + if lora_trigger: + logger.info(f'LoRA trigger word "{lora_trigger}" will be prepended to all captions') + + # Process embeddings + compute_captions_embeddings( + dataset_file=dataset_file, + output_dir=output_dir, + model_path=model_path, + text_encoder_path=text_encoder_path, + caption_column=caption_column, + media_column=media_column, + lora_trigger=lora_trigger, + remove_llm_prefixes=remove_llm_prefixes, + batch_size=batch_size, + device=device, + load_in_8bit=load_text_encoder_in_8bit, + overwrite=overwrite, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/process_dataset.py b/packages/ltx-trainer/scripts/process_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..74d3e3970655bf1a1ef27f0e1dc48bb4a477b7e3 --- /dev/null +++ b/packages/ltx-trainer/scripts/process_dataset.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 + +""" +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 \\ + --model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma +""" + +from pathlib import Path + +import typer +from decode_latents import LatentsDecoder +from process_captions import compute_captions_embeddings +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 +from ltx_trainer.gpu_utils import free_gpu_memory_context + +console = Console() + +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + 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: PLR0912, PLR0913, PLR0915 + dataset_file: str, + 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_downscale_factor: int = 1, + 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 convention-based column detection.""" + _validate_dataset_file(dataset_file) + + # 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" + + 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(): + compute_captions_embeddings( + dataset_file=dataset_file, + output_dir=str(output_base / "conditions"), + model_path=model_path, + text_encoder_path=text_encoder_path, + 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, + device=device, + load_in_8bit=load_text_encoder_in_8bit, + overwrite=overwrite, + ) + + # --- 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 + + 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, + 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, + ) + + # 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") + + # --- Summary --- + logger.info(f"Dataset preprocessing complete! Results saved to {output_base}") + 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) with columns matching the convention table", + ), + 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( + ..., + help="Path to LTX-2 checkpoint (.safetensors file)", + ), + text_encoder_path: str = typer.Option( + ..., + help="Path to Gemma text encoder directory", + ), + caption_column: str | None = typer.Option( + default=None, + help="Override: treat this column as 'caption' (default: auto-detect 'caption')", + ), + 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, + help="Batch size for preprocessing", + ), + device: str = typer.Option( + default="cuda", + help="Device to use for computation", + ), + vae_tiling: bool = typer.Option( + default=False, + help="Enable VAE tiling for larger video resolutions", + ), + output_dir: str | None = typer.Option( + default=None, + help="Output directory (defaults to .precomputed in dataset directory)", + ), + lora_trigger: str | None = typer.Option( + default=None, + help="Optional trigger word to prepend to each caption", + ), + decode: bool = typer.Option( + default=False, + help="Decode and save latents after encoding for verification", + ), + remove_llm_prefixes: bool = typer.Option( + default=False, + help="Remove LLM prefixes from captions", + ), + 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='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, + 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", + ), + reference_downscale_factor: int = typer.Option( + default=1, + 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, + help="Re-compute every item even if its output exists. Use when rerunning with " + "changed parameters (different model, resolution, etc.) so stale outputs are replaced.", + ), +) -> None: + """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. + """ + # Handle deprecated --with-audio flag + if with_audio: + logger.warning( + "--with-audio is deprecated. Audio extraction is now on by default. Use --skip-audio to disable." + ) + + 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_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, + 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_downscale_factor=reference_downscale_factor, + 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, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/process_videos.py b/packages/ltx-trainer/scripts/process_videos.py new file mode 100644 index 0000000000000000000000000000000000000000..7d2f96c5d65755c7538e97d9d5180ec1b45f76e0 --- /dev/null +++ b/packages/ltx-trainer/scripts/process_videos.py @@ -0,0 +1,1474 @@ +#!/usr/bin/env python3 + +""" +Compute latent representations for video generation training. +This module provides functionality for processing video and image files, including: +- Loading videos/images from various file formats (CSV, JSON, JSONL) +- Resizing, cropping, and transforming media +- MediaDataset for video-only preprocessing workflows +- BucketSampler for grouping videos by resolution +Can be used as a standalone script: + python scripts/process_videos.py dataset.csv --resolution-buckets 768x768x25 \ + --output-dir /path/to/output --model-source /path/to/ltx2.safetensors +""" + +import json +import math +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import torch +import torchaudio +import typer +from accelerate import PartialState +from pillow_heif import register_heif_opener +from rich.console import Console +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +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 +from ltx_core.types import Audio +from ltx_trainer import logger +from ltx_trainer.model_loader import load_audio_vae_encoder, load_video_vae_encoder +from ltx_trainer.utils import open_image_as_srgb +from ltx_trainer.video_utils import get_video_frame_count, read_video + +disable_progress_bar() + +# Register HEIF/HEIC support +register_heif_opener() + +# Constants for validation +VAE_SPATIAL_FACTOR = 32 +VAE_TEMPORAL_FACTOR = 8 + +# Audio constants +AUDIO_LATENT_CHANNELS = 8 +AUDIO_FREQUENCY_BINS = 16 + +DEFAULT_TILE_SIZE = 512 # Spatial tile size in pixels (must be ≥64 and divisible by 32) +DEFAULT_TILE_OVERLAP = 128 # Spatial tile overlap in pixels (must be divisible by 32) + +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Process videos/images and save latent representations for video generation training.", +) + + +def _clamp_01(x: torch.Tensor) -> torch.Tensor: + return x.clamp_(0, 1) + + +class MediaDataset(Dataset): + """ + Dataset for processing video and image files. + This dataset is designed for media preprocessing workflows where you need to: + - Load and preprocess videos/images + - Apply resizing and cropping transformations + - Handle different resolution buckets + - Filter out invalid media files + - Optionally extract audio from video files + """ + + def __init__( + self, + dataset_file: str | Path, + main_media_column: str, + video_column: str, + 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. + Args: + dataset_file: Path to CSV/JSON/JSONL metadata file + video_column: Column name for video paths in the metadata file + 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__() + + self.dataset_file = Path(dataset_file) + self.main_media_column = main_media_column + 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) + + # Then load reference video paths + self.video_paths = self._load_video_paths(video_column) + + # Filter out videos with insufficient frames + self._filter_valid_videos() + + self.max_target_frames = max(self.resolution_buckets, key=lambda x: x[0])[0] + + # Set up video transforms + self.transforms = transforms.Compose( + [ + transforms.Lambda(_clamp_01), + transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5], inplace=True), + ] + ) + + def __len__(self) -> int: + return len(self.video_paths) + + def __getitem__(self, index: int) -> dict[str, Any]: + """Get a single video/image with metadata, and optionally audio.""" + if isinstance(index, list): + # Special case for BucketSampler - return cached data + return index + + video_path: Path = self.video_paths[index] + + # Compute relative path of the video + data_root = self.dataset_file.parent + 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) + fps = 1.0 + audio_data = None # Images don't have audio + else: + media_tensor, fps = self._preprocess_video(video_path) + + # Extract audio if enabled + if self.with_audio: + # Calculate target duration from the processed video frames + # This ensures audio is trimmed to match the exact video duration + # media_tensor is [C, F, H, W] so shape[1] is num_frames + target_duration = media_tensor.shape[1] / fps + audio_data = self._extract_audio(video_path, target_duration) + else: + audio_data = None + + # media_tensor is [C, F, H, W] format for VAE compatibility + _, num_frames, height, width = media_tensor.shape + + result = { + "video": media_tensor, + "relative_path": relative_path, + "main_media_relative_path": media_relative_path, + "video_metadata": { + "num_frames": num_frames, + "height": height, + "width": width, + "fps": fps, + }, + } + + # Add audio data if available + if audio_data is not None: + result["audio"] = audio_data + + return result + + @staticmethod + def _extract_audio(video_path: Path, target_duration: float) -> dict[str, torch.Tensor | int] | None: + """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 + + # 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: + waveform = audio.waveform + + return {"waveform": waveform, "sample_rate": audio.sampling_rate} + + 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.""" + original_length = len(self.video_paths) + valid_video_paths = [] + valid_main_media_paths = [] + min_frames_required = min(self.resolution_buckets, key=lambda x: x[0])[0] + + for i, video_path in enumerate(self.video_paths): + if video_path.suffix.lower() in [".png", ".jpg", ".jpeg"]: + valid_video_paths.append(video_path) + valid_main_media_paths.append(self.main_media_paths[i]) + continue + + try: + frame_count = get_video_frame_count(video_path) + + if frame_count >= min_frames_required: + valid_video_paths.append(video_path) + valid_main_media_paths.append(self.main_media_paths[i]) + else: + logger.warning( + f"Skipping video at {video_path} - has {frame_count} frames, " + f"which is less than the minimum required frames ({min_frames_required})" + ) + except Exception as e: + logger.warning(f"Failed to read video at {video_path}: {e!s}") + + # Update both path lists to maintain synchronization + self.video_paths = valid_video_paths + self.main_media_paths = valid_main_media_paths + + if len(self.video_paths) < original_length: + logger.warning( + f"Filtered out {original_length - len(self.video_paths)} videos with insufficient frames. " + f"Proceeding with {len(self.video_paths)} valid videos." + ) + + def _preprocess_image(self, path: Path) -> torch.Tensor: + """Preprocess a single image by resizing and applying transforms.""" + image = open_image_as_srgb(path) + image = to_tensor(image) + image = image.unsqueeze(0) # Add frame dimension [1, C, H, W] for bucket selection + + # Find nearest resolution bucket and resize + nearest_bucket = self._get_resolution_bucket_for_item(image) + _, target_height, target_width = nearest_bucket + image_resized = self._resize_and_crop(image, target_height, target_width) + # _resize_and_crop returns [C, H, W] for single-frame input (squeeze removes dim 0) + + # Apply transforms + image = self.transforms(image_resized) # [C, H, W] -> [C, H, W] + + # Add frame dimension in VAE format: [C, H, W] -> [C, 1, H, W] + image = image.unsqueeze(1) + return image + + def _preprocess_video(self, path: Path) -> tuple[torch.Tensor, float]: + """Preprocess a video by loading, resizing, and applying transforms. + Returns: + Tuple of (video tensor in [C, F, H, W] format, fps) + """ + # Load video frames up to max_target_frames + video, fps = read_video(path, max_frames=self.max_target_frames) + + nearest_bucket = self._get_resolution_bucket_for_item(video) + target_num_frames, target_height, target_width = nearest_bucket + frames_resized = self._resize_and_crop(video, target_height, target_width) + + # 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) + + # Permute [F,C,H,W] -> [C,F,H,W] for VAE compatibility + # After DataLoader batching, this becomes [B,C,F,H,W] which VAE expects + video = video.permute(1, 0, 2, 3).contiguous() + + return video, fps + + def _get_resolution_bucket_for_item(self, media_tensor: torch.Tensor) -> tuple[int, int, int]: + """Get the nearest resolution bucket for the given media tensor.""" + num_frames, _, height, width = media_tensor.shape + + def distance(bucket: tuple[int, int, int]) -> tuple: + bucket_num_frames, bucket_height, bucket_width = bucket + # Lexicographic key: + # 1) minimize aspect-ratio diff (in log-scale, for invariance to shorter/longer ARs) + # 2) prefer buckets with more frames (by using negative) + # 3) prefer buckets with larger spatial area (by using negative) + return ( + abs(math.log(width / height) - math.log(bucket_width / bucket_height)), + -bucket_num_frames, + -(bucket_height * bucket_width), + ) + + # Keep only buckets with <= available frames + relevant_buckets = [b for b in self.resolution_buckets if b[0] <= num_frames] + if not relevant_buckets: + raise ValueError(f"No resolution buckets have <= {num_frames} frames. Available: {self.resolution_buckets}") + + # Find the bucket with the minimal distance (according to the function above) to the media item's shape. + nearest_bucket = min(relevant_buckets, key=distance) + + return nearest_bucket + + def _resize_and_crop(self, media_tensor: torch.Tensor, target_height: int, target_width: int) -> torch.Tensor: + """Resize and crop tensor to target size.""" + # Get current dimensions + current_height, current_width = media_tensor.shape[2], media_tensor.shape[3] + + # Calculate aspect ratios to determine which dimension to resize first + current_aspect = current_width / current_height + target_aspect = target_width / target_height + + # Resize while maintaining aspect ratio - scale to make the smaller dimension fit + if current_aspect > target_aspect: + # Current is wider than target, so scale by height + new_width = int(current_width * target_height / current_height) + media_tensor = resize( + media_tensor, + size=[target_height, new_width], # type: ignore + interpolation=InterpolationMode.BICUBIC, + ) + else: + # Current is taller than target, so scale by width + new_height = int(current_height * target_width / current_width) + media_tensor = resize( + media_tensor, + size=[new_height, target_width], + interpolation=InterpolationMode.BICUBIC, + ) + + # Update dimensions after resize + current_height, current_width = media_tensor.shape[2], media_tensor.shape[3] + media_tensor = media_tensor.squeeze(0) + + # Calculate how much we need to crop from each dimension + delta_h = current_height - target_height + delta_w = current_width - target_width + + # Determine crop position based on reshape mode + if self.reshape_mode == "random": + # Random crop position + top = np.random.randint(0, delta_h + 1) + left = np.random.randint(0, delta_w + 1) + elif self.reshape_mode == "center": + # Center crop + top, left = delta_h // 2, delta_w // 2 + else: + raise ValueError(f"Unsupported reshape mode: {self.reshape_mode}") + + # Perform the final crop to exact target dimensions + media_tensor = crop(media_tensor, top=top, left=left, height=target_height, width=target_width) + return media_tensor + + +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]], + output_dir: str, + model_path: str, + main_media_column: str | None = None, + reshape_mode: str = "center", + batch_size: int = 1, + device: str = "cuda", + 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. + Under ``accelerate launch``, each process handles an interleaved shard of + the dataset (rank/world read from ``accelerate.PartialState``). Already- + computed ``.pt`` outputs are skipped unless ``overwrite=True``; writes are + atomic so an interrupted run is safe to resume. + Args: + dataset_file: Path to metadata file (CSV/JSON/JSONL) containing video paths + video_column: Column name for video paths in the metadata file + resolution_buckets: List of (frames, height, width) tuples + output_dir: Directory to save video latents + model_path: Path to LTX-2 checkpoint (.safetensors) + reshape_mode: How to crop videos ("center", "random") + main_media_column: Column name for main media paths (if different from video_column) + batch_size: Batch size for processing + device: Device to use for computation + 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") + + console = Console() + torch_device = torch.device(device) + + dataset = MediaDataset( + dataset_file=dataset_file, + main_media_column=main_media_column or video_column, + video_column=video_column, + 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) + audio_output_path.mkdir(parents=True, exist_ok=True) + + # Audio processing requires batch_size=1; must be applied before the dataloader is built. + if with_audio and batch_size > 1: + logger.warning("Audio processing requires batch_size=1. Overriding batch_size to 1.") + batch_size = 1 + + data_root = Path(dataset_file).parent + + def _is_done(idx: int) -> bool: + 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() + + dataloader = _build_sharded_dataloader( + dataset, + batch_size=batch_size, + num_workers=num_dataloader_workers, + is_done=_is_done, + overwrite=overwrite, + ) + if dataloader is None: + return + + 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) + + audio_vae_encoder = None + audio_processor = None + if with_audio: + 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 VAE needs float32 for quality. TODO: re-test with bfloat16. + ) + 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) + + # Track audio statistics + audio_success_count = 0 + audio_skip_count = 0 + + # Process batches + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task("Processing videos", total=len(dataloader)) + + for batch in dataloader: + # Get video tensor - shape is [B, F, C, H, W] from DataLoader + video = batch["video"] + + # Encode video + with torch.inference_mode(): + 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"])): + output_rel_path = Path(batch["main_media_relative_path"][i]).with_suffix(".pt") + output_file = output_path / output_rel_path + + # Create output directory maintaining structure + output_file.parent.mkdir(parents=True, exist_ok=True) + + # 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": effective_fps, + } + + _atomic_save(latent_data, output_file) + + # Process audio if enabled (audio is already extracted by the dataset) + if with_audio: + audio_batch = batch.get("audio") + if audio_batch is not None: + # Extract the i-th item from batched audio data + # DataLoader collates [channels, samples] -> [batch, channels, samples] + audio_data = Audio( + waveform=audio_batch["waveform"][i], + sampling_rate=audio_batch["sample_rate"][i].item(), + ) + + # Encode audio + with torch.inference_mode(): + audio_latents = _encode_audio(audio_vae_encoder, audio_processor, audio_data) + + # Save audio latents + audio_output_file = audio_output_path / output_rel_path + audio_output_file.parent.mkdir(parents=True, exist_ok=True) + + audio_save_data = { + "latents": audio_latents["latents"].cpu().contiguous(), + "num_time_steps": audio_latents["num_time_steps"], + "frequency_bins": audio_latents["frequency_bins"], + "duration": audio_latents["duration"], + } + + _atomic_save(audio_save_data, audio_output_file) + audio_success_count += 1 + else: + # Video has no audio track + audio_skip_count += 1 + + progress.advance(task) + + logger.info(f"Processed {len(dataloader.dataset)} videos -> {output_path}") # type: ignore[arg-type] + if with_audio: + logger.info( + f"Audio processing: {audio_success_count} videos with audio, " + f"{audio_skip_count} videos without audio (skipped)" + ) + + +def _encode_video( + vae: torch.nn.Module, + video: torch.Tensor, + dtype: torch.dtype | None = None, + use_tiling: bool = False, + tile_size: int = DEFAULT_TILE_SIZE, + tile_overlap: int = DEFAULT_TILE_OVERLAP, +) -> dict[str, torch.Tensor | int]: + """Encode video into non-patchified latent representation. + Args: + vae: Video VAE encoder model + video: Input tensor of shape [B, C, F, H, W] (batch, channels, frames, height, width) + This is the format expected by the VAE encoder. + dtype: Target dtype for output latents + use_tiling: Whether to use spatial tiling for memory efficiency + tile_size: Tile size in pixels (must be divisible by 32) + tile_overlap: Overlap between tiles in pixels (must be divisible by 32) + Returns: + Dict containing non-patchified latents and shape information: + { + "latents": Tensor[B, C, F', H', W'], # Non-patchified format with batch dim + "num_frames": int, # Latent frame count + "height": int, # Latent height + "width": int, # Latent width + } + """ + device = next(vae.parameters()).device + vae_dtype = next(vae.parameters()).dtype + + # Add batch dimension if needed + if video.ndim == 4: + video = video.unsqueeze(0) # [C, F, H, W] -> [B, C, F, H, W] + + video = video.to(device=device, dtype=vae_dtype) + + # Choose encoding method based on tiling flag + if use_tiling: + latents = _tiled_encode_video( + vae=vae, + video=video, + tile_size=tile_size, + tile_overlap=tile_overlap, + ) + else: + # Encode video - VAE expects [B, C, F, H, W], returns [B, C, F', H', W'] + latents = vae(video) + + if dtype is not None: + latents = latents.to(dtype=dtype) + + _, _, num_frames, height, width = latents.shape + + return { + "latents": latents, # [B, C, F', H', W'] + "num_frames": num_frames, + "height": height, + "width": width, + } + + +def _tiled_encode_video( # noqa: PLR0912, PLR0915 + vae: torch.nn.Module, + video: torch.Tensor, + tile_size: int = DEFAULT_TILE_SIZE, + tile_overlap: int = DEFAULT_TILE_OVERLAP, +) -> torch.Tensor: + """Encode video using spatial tiling for memory efficiency. + Splits the video into overlapping spatial tiles, encodes each tile separately, + and blends the results using linear feathering in the overlap regions. + Args: + vae: Video VAE encoder model + video: Input tensor of shape [B, C, F, H, W] + tile_size: Tile size in pixels (must be divisible by 32) + tile_overlap: Overlap between tiles in pixels (must be divisible by 32) + Returns: + Encoded latent tensor [B, C_latent, F_latent, H_latent, W_latent] + """ + batch, _channels, frames, height, width = video.shape + device = video.device + dtype = video.dtype + + # Validate tile parameters + if tile_size % VAE_SPATIAL_FACTOR != 0: + raise ValueError(f"tile_size must be divisible by {VAE_SPATIAL_FACTOR}, got {tile_size}") + if tile_overlap % VAE_SPATIAL_FACTOR != 0: + raise ValueError(f"tile_overlap must be divisible by {VAE_SPATIAL_FACTOR}, got {tile_overlap}") + if tile_overlap >= tile_size: + raise ValueError(f"tile_overlap ({tile_overlap}) must be less than tile_size ({tile_size})") + + # If video fits in a single tile, use regular encoding + if height <= tile_size and width <= tile_size: + return vae(video) + + # Calculate output dimensions + # VAE compresses: H -> H/32, W -> W/32, F -> 1 + (F-1)/8 + output_height = height // VAE_SPATIAL_FACTOR + output_width = width // VAE_SPATIAL_FACTOR + output_frames = 1 + (frames - 1) // VAE_TEMPORAL_FACTOR + + # Latent channels (128 for LTX-2) + # Get from a small test encode or assume 128 + latent_channels = 128 + + # Initialize output and weight tensors + output = torch.zeros( + (batch, latent_channels, output_frames, output_height, output_width), + device=device, + dtype=dtype, + ) + weights = torch.zeros( + (batch, 1, output_frames, output_height, output_width), + device=device, + dtype=dtype, + ) + + # Calculate tile positions with overlap + # Step size is tile_size - tile_overlap + step_h = tile_size - tile_overlap + step_w = tile_size - tile_overlap + + h_positions = list(range(0, max(1, height - tile_overlap), step_h)) + w_positions = list(range(0, max(1, width - tile_overlap), step_w)) + + # Ensure last tile covers the edge + if h_positions[-1] + tile_size < height: + h_positions.append(height - tile_size) + if w_positions[-1] + tile_size < width: + w_positions.append(width - tile_size) + + # Remove duplicates and sort + h_positions = sorted(set(h_positions)) + w_positions = sorted(set(w_positions)) + + # Overlap in latent space + overlap_out_h = tile_overlap // VAE_SPATIAL_FACTOR + overlap_out_w = tile_overlap // VAE_SPATIAL_FACTOR + + # Process each tile + for h_pos in h_positions: + for w_pos in w_positions: + # Calculate tile boundaries in input space + h_start = max(0, h_pos) + w_start = max(0, w_pos) + h_end = min(h_start + tile_size, height) + w_end = min(w_start + tile_size, width) + + # Ensure tile dimensions are divisible by VAE_SPATIAL_FACTOR + tile_h = ((h_end - h_start) // VAE_SPATIAL_FACTOR) * VAE_SPATIAL_FACTOR + tile_w = ((w_end - w_start) // VAE_SPATIAL_FACTOR) * VAE_SPATIAL_FACTOR + + if tile_h < VAE_SPATIAL_FACTOR or tile_w < VAE_SPATIAL_FACTOR: + continue + + # Adjust end positions + h_end = h_start + tile_h + w_end = w_start + tile_w + + # Extract tile + tile = video[:, :, :, h_start:h_end, w_start:w_end] + + # Encode tile + encoded_tile = vae(tile) + + # Get actual encoded dimensions + _, _, tile_out_frames, tile_out_height, tile_out_width = encoded_tile.shape + + # Calculate output positions + out_h_start = h_start // VAE_SPATIAL_FACTOR + out_w_start = w_start // VAE_SPATIAL_FACTOR + out_h_end = min(out_h_start + tile_out_height, output_height) + out_w_end = min(out_w_start + tile_out_width, output_width) + + # Trim encoded tile if necessary + actual_tile_h = out_h_end - out_h_start + actual_tile_w = out_w_end - out_w_start + encoded_tile = encoded_tile[:, :, :, :actual_tile_h, :actual_tile_w] + + # Create blending mask with linear feathering at edges + mask = torch.ones( + (1, 1, tile_out_frames, actual_tile_h, actual_tile_w), + device=device, + dtype=dtype, + ) + + # Apply feathering at edges (linear blend in overlap regions) + # Left edge + if h_pos > 0 and overlap_out_h > 0 and overlap_out_h < actual_tile_h: + fade_in = torch.linspace(0.0, 1.0, overlap_out_h + 2, device=device, dtype=dtype)[1:-1] + mask[:, :, :, :overlap_out_h, :] *= fade_in.view(1, 1, 1, -1, 1) + + # Right edge (bottom in height dimension) + if h_end < height and overlap_out_h > 0 and overlap_out_h < actual_tile_h: + fade_out = torch.linspace(1.0, 0.0, overlap_out_h + 2, device=device, dtype=dtype)[1:-1] + mask[:, :, :, -overlap_out_h:, :] *= fade_out.view(1, 1, 1, -1, 1) + + # Top edge (left in width dimension) + if w_pos > 0 and overlap_out_w > 0 and overlap_out_w < actual_tile_w: + fade_in = torch.linspace(0.0, 1.0, overlap_out_w + 2, device=device, dtype=dtype)[1:-1] + mask[:, :, :, :, :overlap_out_w] *= fade_in.view(1, 1, 1, 1, -1) + + # Bottom edge (right in width dimension) + if w_end < width and overlap_out_w > 0 and overlap_out_w < actual_tile_w: + fade_out = torch.linspace(1.0, 0.0, overlap_out_w + 2, device=device, dtype=dtype)[1:-1] + mask[:, :, :, :, -overlap_out_w:] *= fade_out.view(1, 1, 1, 1, -1) + + # Accumulate weighted results + output[:, :, :, out_h_start:out_h_end, out_w_start:out_w_end] += encoded_tile * mask + weights[:, :, :, out_h_start:out_h_end, out_w_start:out_w_end] += mask + + # Normalize by weights (avoid division by zero) + output = output / (weights + 1e-8) + + return output + + +def _encode_audio( + audio_vae_encoder: torch.nn.Module, + audio_processor: torch.nn.Module, + audio: Audio, +) -> dict[str, torch.Tensor | int | float]: + """Encode audio waveform into latent representation. + Args: + audio_vae_encoder: Audio VAE encoder model from ltx-core + audio_processor: AudioProcessor for waveform-to-spectrogram conversion + audio: Audio container with waveform tensor and sampling rate. + Returns: + Dict containing audio latents and shape information: + { + "latents": Tensor[C, T, F], # Non-patchified format + "num_time_steps": int, + "frequency_bins": int, + "duration": float, + } + """ + device = next(audio_vae_encoder.parameters()).device + dtype = next(audio_vae_encoder.parameters()).dtype + + waveform = audio.waveform.to(device=device, dtype=dtype) + + # Add batch dimension if needed: [channels, samples] -> [batch, channels, samples] + 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 + + # Convert waveform to mel spectrogram using AudioProcessor + mel_spectrogram = audio_processor.waveform_to_mel(Audio(waveform=waveform, sampling_rate=audio.sampling_rate)) + mel_spectrogram = mel_spectrogram.to(dtype=dtype) + + # Encode mel spectrogram to latents + latents = audio_vae_encoder(mel_spectrogram) + + # latents shape: [batch, channels, time, freq] = [1, 8, T, 16] + _, _channels, time_steps, freq_bins = latents.shape + + return { + "latents": latents.squeeze(0), # [C, T, F] - remove batch dim + "num_time_steps": time_steps, + "frequency_bins": freq_bins, + "duration": duration, + } + + +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 = [] + for bucket_str in resolution_buckets_str.split(";"): + w, h, f = map(int, bucket_str.split("x")) + + if w % VAE_SPATIAL_FACTOR != 0 or h % VAE_SPATIAL_FACTOR != 0: + raise typer.BadParameter( + f"Width and height must be multiples of {VAE_SPATIAL_FACTOR}, got {w}x{h}", + param_hint="resolution-buckets", + ) + + if f % VAE_TEMPORAL_FACTOR != 1: + raise typer.BadParameter( + f"Number of frames must be a multiple of {VAE_TEMPORAL_FACTOR} plus 1, got {f}", + param_hint="resolution-buckets", + ) + + resolution_buckets.append((f, h, w)) + return resolution_buckets + + +def compute_scaled_resolution_buckets( + resolution_buckets: list[tuple[int, int, int]], + scale_factor: int, +) -> list[tuple[int, int, int]]: + """Compute scaled resolution buckets and validate the results.""" + if scale_factor == 1: + return resolution_buckets + + scaled_buckets = [] + for frames, height, width in resolution_buckets: + # Validate that scale factor evenly divides the dimensions + if height % scale_factor != 0: + raise ValueError( + f"Height {height} is not evenly divisible by scale factor {scale_factor}. " + f"Choose a scale factor that divides {height} evenly." + ) + if width % scale_factor != 0: + raise ValueError( + f"Width {width} is not evenly divisible by scale factor {scale_factor}. " + f"Choose a scale factor that divides {width} evenly." + ) + + scaled_height = height // scale_factor + scaled_width = width // scale_factor + + # Validate scaled dimensions are divisible by VAE spatial factor + if scaled_height % VAE_SPATIAL_FACTOR != 0: + raise ValueError( + f"Scaled height {scaled_height} (from {height} / {scale_factor}) " + f"is not divisible by {VAE_SPATIAL_FACTOR}. " + f"Choose a different scale factor or adjust your resolution buckets." + ) + if scaled_width % VAE_SPATIAL_FACTOR != 0: + raise ValueError( + f"Scaled width {scaled_width} (from {width} / {scale_factor}) " + f"is not divisible by {VAE_SPATIAL_FACTOR}. " + f"Choose a different scale factor or adjust your resolution buckets." + ) + + scaled_buckets.append((frames, scaled_height, scaled_width)) + + return scaled_buckets + + +def _atomic_save(data: Any, out: Path) -> None: # noqa: ANN401 + """Save to ``out`` atomically via per-PID temp file + replace. + Crash mid-write leaves an orphan ``.tmp.`` file that the skip logic + ignores. The per-PID suffix makes concurrent writes from multiple ranks + collision-free. + """ + tmp = out.with_suffix(f"{out.suffix}.tmp.{os.getpid()}") + torch.save(data, tmp) + tmp.replace(out) + + +def _build_sharded_dataloader( + dataset: Dataset, + *, + batch_size: int, + num_workers: int, + is_done: Callable[[int], bool], + overwrite: bool, +) -> DataLoader | None: + """Return a DataLoader over this rank's interleaved shard of ``dataset``. + When ``overwrite`` is False, items whose outputs already exist (per + ``is_done``) are filtered out. Returns ``None`` if this rank has nothing + to do, so the caller can early-return without loading any models. + """ + state = PartialState() + todo = [i for i in range(state.process_index, len(dataset), state.num_processes) if overwrite or not is_done(i)] + if not todo: + logger.info(f"Rank {state.process_index}/{state.num_processes}: nothing to do") + return None + logger.info(f"Rank {state.process_index}/{state.num_processes}: processing {len(todo):,} of {len(dataset):,} items") + return DataLoader(Subset(dataset, todo), batch_size=batch_size, shuffle=False, num_workers=num_workers) + + +@app.command() +def main( # noqa: PLR0913 + dataset_file: str = typer.Argument( + ..., + help="Path to metadata file (CSV/JSON/JSONL) containing video paths", + ), + resolution_buckets: str = typer.Option( + ..., + help='Resolution buckets in format "WxHxF;WxHxF;..." (e.g. "768x768x25;512x512x49")', + ), + output_dir: str = typer.Option( + ..., + help="Output directory to save video latents", + ), + model_path: str = typer.Option( + ..., + help="Path to LTX-2 checkpoint (.safetensors file)", + ), + video_column: str = typer.Option( + default="media_path", + help="Column name in the dataset JSON/JSONL/CSV file containing video paths", + ), + batch_size: int = typer.Option( + default=1, + help="Batch size for processing", + ), + device: str = typer.Option( + default="cuda", + help="Device to use for computation", + ), + vae_tiling: bool = typer.Option( + default=False, + help="Enable VAE tiling for larger video resolutions", + ), + reshape_mode: str = typer.Option( + default="center", + help="How to crop videos: 'center' or 'random'", + ), + with_audio: bool = typer.Option( + default=False, + help="Extract and encode audio from video files", + ), + audio_output_dir: str | None = typer.Option( + default=None, + help="Output directory for audio latents (required if --with-audio is set)", + ), + overwrite: bool = typer.Option( + default=False, + help="Re-encode every item even if its output exists. Use when rerunning with " + "changed parameters (different model, resolution, etc.) so stale outputs are replaced.", + ), +) -> None: + """Process videos/images and save latent representations for video generation training. + 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 \\ + --output-dir ./latents --model-path /path/to/ltx2.safetensors + # Process videos from a JSON file with custom video column + python scripts/process_videos.py dataset.json --resolution-buckets 768x768x25 \\ + --output-dir ./latents --model-path /path/to/ltx2.safetensors --video-column "video_path" + # Enable VAE tiling to save GPU VRAM + python scripts/process_videos.py dataset.csv --resolution-buckets 1024x1024x25 \\ + --output-dir ./latents --model-path /path/to/ltx2.safetensors --vae-tiling + # Process videos with audio + python scripts/process_videos.py dataset.csv --resolution-buckets 768x768x25 \\ + --output-dir ./latents --model-path /path/to/ltx2.safetensors \\ + --with-audio --audio-output-dir ./audio_latents + """ + + # Validate dataset file exists + if not Path(dataset_file).is_file(): + raise typer.BadParameter(f"Dataset file not found: {dataset_file}") + + # Validate audio parameters + if with_audio and audio_output_dir is None: + raise typer.BadParameter("--audio-output-dir is required when --with-audio is set") + + # Parse resolution buckets + parsed_resolution_buckets = parse_resolution_buckets(resolution_buckets) + + if len(parsed_resolution_buckets) > 1: + logger.warning( + "Using multiple resolution buckets. " + "When training with multiple resolution buckets, you must use a batch size of 1." + ) + + # Process latents + compute_latents( + dataset_file=dataset_file, + video_column=video_column, + resolution_buckets=parsed_resolution_buckets, + output_dir=output_dir, + model_path=model_path, + reshape_mode=reshape_mode, + batch_size=batch_size, + device=device, + vae_tiling=vae_tiling, + with_audio=with_audio, + audio_output_dir=audio_output_dir, + overwrite=overwrite, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/serve_captioner.py b/packages/ltx-trainer/scripts/serve_captioner.py new file mode 100644 index 0000000000000000000000000000000000000000..4cecc1070f70f01994ed79f0d5e1fe57d45f52a7 --- /dev/null +++ b/packages/ltx-trainer/scripts/serve_captioner.py @@ -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() diff --git a/packages/ltx-trainer/scripts/split_scenes.py b/packages/ltx-trainer/scripts/split_scenes.py new file mode 100644 index 0000000000000000000000000000000000000000..0cfce5c6baedb5e26e22499ae9c804d1fa9e181b --- /dev/null +++ b/packages/ltx-trainer/scripts/split_scenes.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 + +""" +Split video into scenes using PySceneDetect. +This script provides a command-line interface for splitting videos into scenes using various detection algorithms. +It supports multiple detection methods, preview image generation, and customizable parameters for fine-tuning +the scene detection process. +Basic usage: + # Split video using default content-based detection + scenes_split.py input.mp4 output_dir/ + # Save 3 preview images per scene + scenes_split.py input.mp4 output_dir/ --save-images 3 + # Process specific duration and filter short scenes + scenes_split.py input.mp4 output_dir/ --duration 60s --filter-shorter-than 2s +Advanced usage: + # Content detection with minimum scene length and frame skip + scenes_split.py input.mp4 output_dir/ --detector content --min-scene-length 30 --frame-skip 2 + # Use adaptive detection with custom detector and detector parameters + scenes_split.py input.mp4 output_dir/ --detector adaptive --threshold 3.0 --adaptive-window 10 +""" + +from enum import Enum +from pathlib import Path +from typing import List, Optional, Tuple + +import typer +from scenedetect import ( + AdaptiveDetector, + ContentDetector, + HistogramDetector, + SceneManager, + ThresholdDetector, + open_video, +) +from scenedetect.frame_timecode import FrameTimecode +from scenedetect.scene_manager import SceneDetector, write_scene_list_html +from scenedetect.scene_manager import save_images as save_scene_images +from scenedetect.stats_manager import StatsManager +from scenedetect.video_splitter import split_video_ffmpeg + +app = typer.Typer(no_args_is_help=True, help="Split video into scenes using PySceneDetect.") + + +class DetectorType(str, Enum): + """Available scene detection algorithms.""" + + CONTENT = "content" # Detects fast cuts using HSV color space + ADAPTIVE = "adaptive" # Detects fast two-phase cuts + THRESHOLD = "threshold" # Detects fast cuts/slow fades in from and out to a given threshold level + HISTOGRAM = "histogram" # Detects based on YUV histogram differences in adjacent frames + + +def create_detector( + detector_type: DetectorType, + threshold: Optional[float] = None, + min_scene_len: Optional[int] = None, + luma_only: Optional[bool] = None, + adaptive_window: Optional[int] = None, + fade_bias: Optional[float] = None, +) -> SceneDetector: + """Create a scene detector based on the specified type and parameters. + Args: + detector_type: Type of detector to create + threshold: Detection threshold (meaning varies by detector) + min_scene_len: Minimum scene length in frames + luma_only: If True, only use brightness for content detection + adaptive_window: Window size for adaptive detection + fade_bias: Bias for fade in/out detection (-1.0 to 1.0) + Note: Parameters set to None will use the detector's built-in default values. + Returns: + Configured scene detector instance + """ + # Set common arguments + kwargs = {} + if threshold is not None: + kwargs["threshold"] = threshold + + if min_scene_len is not None: + kwargs["min_scene_len"] = min_scene_len + + match detector_type: + case DetectorType.CONTENT: + if luma_only is not None: + kwargs["luma_only"] = luma_only + return ContentDetector(**kwargs) + case DetectorType.ADAPTIVE: + if adaptive_window is not None: + kwargs["window_width"] = adaptive_window + if luma_only is not None: + kwargs["luma_only"] = luma_only + if "threshold" in kwargs: + # Special case for adaptive detector which uses different param name + kwargs["adaptive_threshold"] = kwargs.pop("threshold") + return AdaptiveDetector(**kwargs) + case DetectorType.THRESHOLD: + if fade_bias is not None: + kwargs["fade_bias"] = fade_bias + return ThresholdDetector(**kwargs) + case DetectorType.HISTOGRAM: + return HistogramDetector(**kwargs) + case _: + raise ValueError(f"Unknown detector type: {detector_type}") + + +def validate_output_dir(output_dir: str) -> Path: + """Validate and create output directory if it doesn't exist. + Args: + output_dir: Path to the output directory + Returns: + Path object of the validated output directory + """ + path = Path(output_dir) + + if path.exists() and not path.is_dir(): + raise typer.BadParameter(f"{output_dir} exists but is not a directory") + + return path + + +def parse_timecode(video: any, time_str: Optional[str]) -> Optional[FrameTimecode]: + """Parse a timecode string into a FrameTimecode object. + Supports formats: + - Frames: '123' + - Seconds: '123s' or '123.45s' + - Timecode: '00:02:03' or '00:02:03.456' + Args: + video: Video object to get framerate from + time_str: String to parse, or None + Returns: + FrameTimecode object or None if input is None + """ + if time_str is None: + return None + + try: + if time_str.endswith("s"): + # Seconds format + seconds = float(time_str[:-1]) + return FrameTimecode(timecode=seconds, fps=video.frame_rate) + elif ":" in time_str: + # Timecode format + return FrameTimecode(timecode=time_str, fps=video.frame_rate) + else: + # Frame number format + return FrameTimecode(timecode=int(time_str), fps=video.frame_rate) + except ValueError as e: + raise typer.BadParameter( + f"Invalid timecode format: {time_str}. Use frames (123), " + f"seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn])", + ) from e + + +def detect_and_split_scenes( # noqa: PLR0913 + video_path: str, + output_dir: Path, + detector_type: DetectorType, + threshold: Optional[float] = None, + min_scene_len: Optional[int] = None, + max_scenes: Optional[int] = None, + filter_shorter_than: Optional[str] = None, + skip_start: Optional[int] = None, # noqa: ARG001 + skip_end: Optional[int] = None, # noqa: ARG001 + save_images_per_scene: int = 0, + stats_file: Optional[str] = None, + luma_only: bool = False, + adaptive_window: Optional[int] = None, + fade_bias: Optional[float] = None, + downscale_factor: Optional[int] = None, + frame_skip: int = 0, + duration: Optional[str] = None, +) -> List[Tuple[FrameTimecode, FrameTimecode]]: + """Detect and split scenes in a video using the specified parameters. + Args: + video_path: Path to input video. + output_dir: Directory to save output split scenes. + detector_type: Type of scene detector to use. + threshold: Detection threshold. + min_scene_len: Minimum scene length in frames. + max_scenes: Maximum number of scenes to detect. + filter_shorter_than: Filter out scenes shorter than this duration (frames/seconds/timecode) + skip_start: Number of frames to skip at start. + skip_end: Number of frames to skip at end. + save_images_per_scene: Number of images to save per scene (0 to disable). + stats_file: Path to save detection statistics (optional). + luma_only: Only use brightness for content detection. + adaptive_window: Window size for adaptive detection. + fade_bias: Bias for fade detection (-1.0 to 1.0). + downscale_factor: Factor to downscale frames by during detection. + frame_skip: Number of frames to skip (i.e. process every 1 in N+1 frames, + where N is frame_skip, processing only 1/N+1 percent of the video, + speeding up the detection time at the expense of accuracy). + frame_skip must be 0 (the default) when using a StatsManager. + duration: How much of the video to process from start position. + Can be specified as frames (123), seconds (123s/123.45s), + or timecode (HH:MM:SS[.nnn]). + Returns: + List of detected scenes as (start, end) FrameTimecode pairs. + """ + # Create video stream + video = open_video(video_path, backend="opencv") + + # Parse duration if specified + duration_tc = parse_timecode(video, duration) + + # Parse filter_shorter_than if specified + filter_shorter_than_tc = parse_timecode(video, filter_shorter_than) + + # Initialize scene manager with optional stats manager + stats_manager = StatsManager() if stats_file else None + scene_manager = SceneManager(stats_manager) + + # Configure scene manager + if downscale_factor: + scene_manager.auto_downscale = False + scene_manager.downscale = downscale_factor + + # Create and add detector + detector = create_detector( + detector_type=detector_type, + threshold=threshold, + min_scene_len=min_scene_len, + luma_only=luma_only, + adaptive_window=adaptive_window, + fade_bias=fade_bias, + ) + scene_manager.add_detector(detector) + + # Detect scenes + typer.echo("Detecting scenes...") + scene_manager.detect_scenes( + video=video, + show_progress=True, + frame_skip=frame_skip, + duration=duration_tc, + ) + + # Get scene list + scenes = scene_manager.get_scene_list() + + # Filter out scenes that are too short if filter_shorter_than is specified + if filter_shorter_than_tc: + original_count = len(scenes) + scenes = [ + (start, end) + for start, end in scenes + if (end.get_frames() - start.get_frames()) >= filter_shorter_than_tc.get_frames() + ] + if len(scenes) < original_count: + typer.echo( + f"Filtered out {original_count - len(scenes)} scenes shorter " + f"than {filter_shorter_than_tc.get_seconds():.1f} seconds " + f"({filter_shorter_than_tc.get_frames()} frames)", + ) + + # Apply max scenes limit if specified + if max_scenes and len(scenes) > max_scenes: + typer.echo(f"Dropping last {len(scenes) - max_scenes} scenes to meet max_scenes ({max_scenes}) limit") + scenes = scenes[:max_scenes] + + # Print scene information + typer.echo(f"Found {len(scenes)} scenes:") + for i, (start, end) in enumerate(scenes, 1): + typer.echo( + f"Scene {i}: {start.get_timecode()} to {end.get_timecode()} " + f"({end.get_frames() - start.get_frames()} frames)", + ) + + # Save stats if requested + if stats_file: + typer.echo(f"Saving detection stats to {stats_file}") + stats_manager.save_to_csv(stats_file) + + # Split video into scenes + typer.echo("Splitting video into scenes...") + try: + split_video_ffmpeg( + input_video_path=video_path, + scene_list=scenes, + output_dir=output_dir, + show_progress=True, + ) + typer.echo(f"Scenes have been saved to: {output_dir}") + except Exception as e: + raise typer.BadParameter(f"Error splitting video: {e}") from e + + # Save preview images if requested + if save_images_per_scene > 0: + typer.echo(f"Saving {save_images_per_scene} preview images per scene...") + image_filenames = save_scene_images( + scene_list=scenes, + video=video, + num_images=save_images_per_scene, + output_dir=str(output_dir), + show_progress=True, + ) + + # Generate HTML report with scene information and previews + html_path = output_dir / "scene_report.html" + write_scene_list_html( + output_html_filename=str(html_path), + scene_list=scenes, + image_filenames=image_filenames, + ) + typer.echo(f"Scene report saved to: {html_path}") + + return scenes + + +@app.command() +def main( # noqa: PLR0913 + video_path: Path = typer.Argument( # noqa: B008 + ..., + help="Path to the input video file", + exists=True, + dir_okay=False, + ), + output_dir: str = typer.Argument( + ..., + help="Directory where split scenes will be saved", + ), + detector: DetectorType = typer.Option( # noqa: B008 + DetectorType.CONTENT, + help="Scene detection algorithm to use", + ), + threshold: Optional[float] = typer.Option( + None, + help="Detection threshold (meaning varies by detector)", + ), + max_scenes: Optional[int] = typer.Option( + None, + help="Maximum number of scenes to produce", + ), + min_scene_length: Optional[int] = typer.Option( + None, + help="Minimum scene length during detection. Forces the detector to make scenes at least this many frames. " + "This affects scene detection behavior but does not filter out short scenes.", + ), + filter_shorter_than: Optional[str] = typer.Option( + None, + help="Filter out scenes shorter than this duration. Can be specified as frames (123), " + "seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn]). These scenes will be detected but not saved.", + ), + skip_start: Optional[int] = typer.Option( + None, + help="Number of frames to skip at the start of the video", + ), + skip_end: Optional[int] = typer.Option( + None, + help="Number of frames to skip at the end of the video", + ), + duration: Optional[str] = typer.Option( + None, + "-d", + help="How much of the video to process. Can be specified as frames (123), " + "seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn])", + ), + save_images: int = typer.Option( + 0, + help="Number of preview images to save per scene (0 to disable)", + ), + stats_file: Optional[str] = typer.Option( + None, + help="Path to save detection statistics CSV", + ), + luma_only: bool = typer.Option( + False, + help="Only use brightness for content detection", + ), + adaptive_window: Optional[int] = typer.Option( + None, + help="Window size for adaptive detection", + ), + fade_bias: Optional[float] = typer.Option( + None, + help="Bias for fade detection (-1.0 to 1.0)", + ), + downscale: Optional[int] = typer.Option( + None, + help="Factor to downscale frames by during detection", + ), + frame_skip: int = typer.Option( + 0, + help="Number of frames to skip during processing", + ), +) -> None: + """Split video into scenes using PySceneDetect.""" + if skip_start or skip_end: + typer.echo("Skipping start and end frames is not supported yet.") + return + + # Validate output directory + output_path = validate_output_dir(output_dir) + + # Detect and split scenes + detect_and_split_scenes( + video_path=str(video_path), + output_dir=output_path, + detector_type=detector, + threshold=threshold, + min_scene_len=min_scene_length, + max_scenes=max_scenes, + filter_shorter_than=filter_shorter_than, + skip_start=skip_start, + skip_end=skip_end, + duration=duration, + save_images_per_scene=save_images, + stats_file=stats_file, + luma_only=luma_only, + adaptive_window=adaptive_window, + fade_bias=fade_bias, + downscale_factor=downscale, + frame_skip=frame_skip, + ) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/scripts/train.py b/packages/ltx-trainer/scripts/train.py new file mode 100644 index 0000000000000000000000000000000000000000..cd58e410a4a43ed7288afd324540c277fe6eab75 --- /dev/null +++ b/packages/ltx-trainer/scripts/train.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +""" +Train LTXV models using configuration from YAML files. +This script provides a command-line interface for training LTXV models using +either LoRA fine-tuning or full model fine-tuning. It loads configuration from +a YAML file and passes it to the trainer. +Basic usage: + python scripts/train.py CONFIG_PATH [--disable-progress-bars] +Resume is automatic when a training state file exists next to the loaded checkpoint. +To start fresh, set `checkpoints.no_resume: true` in the YAML config. +For multi-GPU/FSDP training, configure and launch via Accelerate: + accelerate config + accelerate launch scripts/train.py CONFIG_PATH +""" + +from pathlib import Path + +import typer +import yaml +from rich.console import Console + +from ltx_trainer.config import LtxTrainerConfig +from ltx_trainer.trainer import LtxvTrainer + +console = Console() +app = typer.Typer( + pretty_exceptions_enable=False, + no_args_is_help=True, + help="Train LTXV models using configuration from YAML files.", +) + + +@app.command() +def main( + config_path: str = typer.Argument(..., help="Path to YAML configuration file"), + disable_progress_bars: bool = typer.Option( + False, + "--disable-progress-bars", + help="Disable progress bars (useful for multi-process runs)", + ), +) -> None: + """Train the model using the provided configuration file.""" + config_path = Path(config_path) + if not config_path.exists(): + typer.echo(f"Error: Configuration file {config_path} does not exist.") + raise typer.Exit(code=1) + + with open(config_path, "r") as file: + config_data = yaml.safe_load(file) + + try: + trainer_config = LtxTrainerConfig(**config_data) + except Exception as e: + typer.echo(f"Error: Invalid configuration data: {e}") + raise typer.Exit(code=1) from e + + trainer = LtxvTrainer(trainer_config) + trainer.train(disable_progress_bars=disable_progress_bars) + + +if __name__ == "__main__": + app() diff --git a/packages/ltx-trainer/src/ltx_trainer/__init__.py b/packages/ltx-trainer/src/ltx_trainer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..56efa403f9384f9dc4f631b4d63a52d2df6a7680 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/__init__.py @@ -0,0 +1,44 @@ +import logging +import os +import sys +from logging import getLogger +from pathlib import Path + +from rich.logging import RichHandler + +# Get the process rank +IS_MULTI_GPU = os.environ.get("LOCAL_RANK") is not None +RANK = int(os.environ.get("LOCAL_RANK", "0")) + +# Configure with Rich +logging.basicConfig( + level="INFO", + format=f"\\[rank {RANK}] %(message)s" if IS_MULTI_GPU else "%(message)s", + handlers=[ + RichHandler( + rich_tracebacks=True, + show_time=False, + markup=True, + ) + ], +) + +# Get the logger and configure it +logger = getLogger("ltxv_trainer") +logger.setLevel(logging.DEBUG) +logger.propagate = True + +# Set level based on process +if RANK != 0: + logger.setLevel(logging.WARNING) + +# Expose common logging functions directly +debug = logger.debug +info = logger.info +warning = logger.warning +error = logger.error +critical = logger.critical + + +# Add the root directory to the Python path so we can import from scripts. +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) diff --git a/packages/ltx-trainer/src/ltx_trainer/captioning.py b/packages/ltx-trainer/src/ltx_trainer/captioning.py new file mode 100644 index 0000000000000000000000000000000000000000..1aa55a8d26fb359481bacb377c26ecaa4a032d6a --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/captioning.py @@ -0,0 +1,455 @@ +""" +Audio-visual media captioning using multimodal models. +This module provides captioning capabilities for videos with audio using: +- 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 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 + +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. + +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...". + +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). + +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. + +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. + +Return a JSON object with exactly one key: + +{"combined_caption_english": ""}""" + + +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": ""}""" + + +# 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" # 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.""" + match captioner_type: + case CaptionerType.QWEN_OMNI: + return QwenOmniCaptioner(**kwargs) + case CaptionerType.GEMINI_FLASH: + return GeminiFlashCaptioner(**kwargs) + case _: + raise ValueError(f"Unsupported captioner type: {captioner_type}") + + +class MediaCaptioningModel(ABC): + """Abstract base class for audio-visual media captioning models.""" + + instruction: str | None = None + + @abstractmethod + def caption(self, path: str | Path, **kwargs) -> str: + """Generate a caption for the given video or image.""" + + 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: + return str(path).lower().endswith((".png", ".jpg", ".jpeg", ".heic", ".heif", ".webp")) + + @staticmethod + def _is_video_file(path: str | Path) -> bool: + return str(path).lower().endswith((".mp4", ".avi", ".mov", ".mkv", ".webm")) + + +class QwenOmniCaptioner(MediaCaptioningModel): + """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. + """ + + def __init__( + self, + 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 Qwen3-Omni captioner. + Args: + 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 + ``...`` 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. + """ + from openai import OpenAI # noqa: PLC0415 + + 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 = 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 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: + 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}") + + instruction = self._resolve_instruction(path) + + if is_image: + content = [ + {"type": "image_url", "image_url": {"url": f"file://{path.resolve()}"}}, + {"type": "text", "text": instruction}, + ] + return _parse_caption_response(self._chat(content)).strip() + + return self._caption_video(path, instruction, fps) + + 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 "" + + 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. + """ + with tempfile.TemporaryDirectory(prefix="qwencap_") as tmp: + work = Path(tmp) + + # 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 + + 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 + + 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 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-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 captioner. + Args: + 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.model = model or self.MODEL_ID + self._client = self._make_client(api_key) + + def caption( + self, + path: str | Path, + fps: int = 2, # noqa: ARG002 - kept for API compatibility + ) -> str: + from google.genai import types # noqa: PLC0415 + + path = Path(path) + 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), + ) + + # Gemini may also return JSON if it followed our prompt format. + return _parse_caption_response(response.text or "").strip() + + @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 + + def _make_client(self, api_key: str | None): # noqa: ANN202 - genai.Client type is lazy-imported + from google import genai # noqa: PLC0415 + + # 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) + + # 2. No key -> Vertex AI via Application Default Credentials (gcloud / service account). + import google.auth # noqa: PLC0415 + + try: + _, 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 + + 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 _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": "..."}`` + - ``...`` chain-of-thought followed by either of the above + - Truncated JSON (when generation hits a token limit mid-string) + """ + text = re.sub(r"[\s\S]*?", "", raw).strip() + + # Thinking models (e.g. Qwen3-Omni-*-Thinking) emit the reasoning trace + # without an opening ```` tag, because the chat template injects it + # for them -- so the response starts mid-thought and is terminated by a lone + # ```` before the real answer. Drop everything up to that closer. + if "" in text: + text = text.rsplit("", 1)[1].strip() + + if not text: + return raw.strip() + + 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 + + +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)]) diff --git a/packages/ltx-trainer/src/ltx_trainer/config.py b/packages/ltx-trainer/src/ltx_trainer/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c4d30092183e978eeeb251cb192216712ea9b820 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/config.py @@ -0,0 +1,833 @@ +from pathlib import Path +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 + + +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""" + + model_path: str | Path = Field( + ..., + description="Model path - local path to safetensors checkpoint file", + ) + + text_encoder_path: str | Path | None = Field( + default=None, + description="Path to text encoder (required for LTX-2/Gemma models, optional for LTXV/T5 models)", + ) + + training_mode: Literal["lora", "full"] = Field( + default="lora", + description="Training mode - either LoRA fine-tuning or full model fine-tuning", + ) + + load_checkpoint: str | Path | None = Field( + default=None, + description="Path to a checkpoint file or directory to load from. " + "If a directory is provided, the latest checkpoint will be used.", + ) + + @field_validator("model_path") + @classmethod + def validate_model_path(cls, v: str | Path) -> str | Path: + """Validate that model_path is either a valid URL or an existing local path.""" + is_url = str(v).startswith(("http://", "https://")) + + if is_url: + raise ValueError(f"Model path cannot be a URL: {v}") + + if not Path(v).exists(): + raise ValueError(f"Model path does not exist: {v}") + + return v + + +class LoraConfig(ConfigBaseModel): + """Configuration for LoRA fine-tuning""" + + rank: int = Field( + default=64, + description="Rank of LoRA adaptation", + ge=2, + ) + + alpha: int = Field( + default=64, + description="Alpha scaling factor for LoRA", + ge=1, + ) + + dropout: float = Field( + default=0.0, + description="Dropout probability for LoRA layers", + ge=0.0, + le=1.0, + ) + + target_modules: list[str] = Field( + default=["to_k", "to_q", "to_v", "to_out.0"], + description="List of modules to target with LoRA", + ) + + +def _get_strategy_discriminator(v: dict | TrainingStrategyConfigBase) -> str: + """Discriminator function for strategy config union.""" + if isinstance(v, dict): + return v.get("name", "text_to_video") + return v.name + + +# Union type for all strategy configs with discriminator +TrainingStrategyConfig = Annotated[ + Annotated[TextToVideoConfig, Tag("text_to_video")] + | Annotated[VideoToVideoConfig, Tag("video_to_video")] + | Annotated[FlexibleStrategyConfig, Tag("flexible")], + Discriminator(_get_strategy_discriminator), +] + + +class OptimizationConfig(ConfigBaseModel): + """Configuration for optimization parameters""" + + learning_rate: float = Field( + default=5e-4, + description="Learning rate for optimization", + ) + + steps: int = Field( + default=3000, + description="Number of training steps", + ) + + batch_size: int = Field( + default=2, + description="Batch size for training", + ) + + gradient_accumulation_steps: int = Field( + default=1, + description="Number of steps to accumulate gradients", + ) + + max_grad_norm: float = Field( + default=1.0, + description="Maximum gradient norm for clipping", + ) + + optimizer_type: Literal["adamw", "adamw8bit"] = Field( + default="adamw", + description="Type of optimizer to use for training", + ) + + scheduler_type: Literal[ + "constant", + "linear", + "cosine", + "cosine_with_restarts", + "polynomial", + "step", + ] = Field( + default="linear", + description="Type of scheduler to use for training", + ) + + scheduler_params: dict = Field( + default_factory=dict, + description="Parameters for the scheduler", + ) + + enable_gradient_checkpointing: bool = Field( + default=False, + description="Enable gradient checkpointing to save memory at the cost of slower training", + ) + + +class AccelerationConfig(ConfigBaseModel): + """Configuration for hardware acceleration and compute optimization""" + + mixed_precision_mode: Literal["no", "fp16", "bf16"] | None = Field( + default="bf16", + description="Mixed precision training mode", + ) + + quantization: QuantizationOptions | None = Field( + default=None, + description="Quantization precision to use", + ) + + load_text_encoder_in_8bit: bool = Field( + default=False, + description="Whether to load the text encoder in 8-bit precision to save memory", + ) + + offload_optimizer_during_validation: bool = Field( + default=False, + description="Offload optimizer state to CPU before validation video sampling and reload " + "it afterwards, to free VRAM for inference. Useful when optimizer state is large " + "(e.g. AdamW for full fine-tuning or high-rank LoRA) and validation OOMs because the " + "VAE decoder + transformer + optimizer state cannot coexist on the GPU. Has no effect " + "for FSDP (sharded state). Disabled by default.", + ) + + +class DataConfig(ConfigBaseModel): + """Configuration for data loading and processing""" + + preprocessed_data_root: str = Field( + description="Path to folder containing preprocessed training data", + ) + + num_dataloader_workers: int = Field( + default=2, + description="Number of background processes for data loading (0 means synchronous loading)", + ge=0, + ) + + @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="[DEPRECATED: use 'samples' instead] List of prompts to use for validation", + ) + + negative_prompt: str = Field( + default="worst quality, inconsistent motion, blurry, jittery, distorted", + description="Negative prompt to use for validation examples", + ) + + images: list[str] | None = Field( + default=None, + description="[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="[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="[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, + ) + + video_dims: tuple[int, int, int] = Field( + default=(960, 544, 97), + description="Dimensions of validation videos (width, height, frames). " + "Width and height must be divisible by 32. Frames must satisfy frames % 8 == 1 for LTX-2.", + ) + + @field_validator("video_dims") + @classmethod + def validate_video_dims(cls, v: tuple[int, int, int]) -> tuple[int, int, int]: + """Validate video dimensions for LTX-2 compatibility.""" + width, height, frames = v + + if width % 32 != 0: + raise ValueError(f"Width ({width}) must be divisible by 32") + if height % 32 != 0: + raise ValueError(f"Height ({height}) must be divisible by 32") + if frames % 8 != 1: + raise ValueError(f"Frames ({frames}) must satisfy frames % 8 == 1 for LTX-2 (e.g., 1, 9, 17, 25, ...)") + + return v + + frame_rate: float = Field( + default=25.0, + description="Frame rate for validation videos", + gt=0, + ) + + seed: int = Field( + default=42, + description="Random seed used when sampling validation videos", + ) + + inference_steps: int = Field( + default=50, + description="Number of inference steps for validation", + gt=0, + ) + + interval: int | None = Field( + default=100, + description="Number of steps between validation runs. If None, validation is disabled.", + gt=0, + ) + + guidance_scale: float = Field( + default=4.0, + description="CFG guidance scale to use during validation", + ge=1.0, + ) + + stg_scale: float = Field( + default=1.0, + description="STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. " + "Recommended value is 1.0. STG is combined with CFG for improved video quality.", + ge=0.0, + ) + + stg_blocks: list[int] | None = Field( + default=[29], + description="Which transformer blocks to perturb for STG. " + "None means all blocks are perturbed. Recommended for LTX-2: [29].", + ) + + stg_mode: Literal["stg_av", "stg_v"] = Field( + default="stg_av", + description="STG mode: 'stg_av' skips both audio and video self-attention, " + "'stg_v' skips only video self-attention.", + ) + + generate_audio: bool = Field( + default=True, + description="Whether to generate audio in validation samples. " + "Independent of training strategy setting - you can generate audio " + "in validation even when not training the audio branch.", + ) + + 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)", + ) + + include_reference_in_output: bool = Field( + default=False, + 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.", + ) + + @field_validator("images") + @classmethod + def validate_images(cls, v: list[str] | None, info: ValidationInfo) -> list[str] | None: + """Validate that number of images (if provided) matches number of prompts.""" + if v is None: + return None + + num_prompts = len(info.data.get("prompts", [])) + if v is not None and len(v) != num_prompts: + raise ValueError(f"Number of images ({len(v)}) must match number of prompts ({num_prompts})") + + for image_path in v: + if not Path(image_path).exists(): + raise ValueError(f"Image path '{image_path}' does not exist") + + return v + + @field_validator("reference_videos") + @classmethod + def validate_reference_videos(cls, v: list[str] | None, info: ValidationInfo) -> list[str] | None: + """Validate that number of reference videos (if provided) matches number of prompts.""" + if v is None: + return None + + num_prompts = len(info.data.get("prompts", [])) + if v is not None and len(v) != num_prompts: + raise ValueError(f"Number of reference videos ({len(v)}) must match number of prompts ({num_prompts})") + + for video_path in v: + if not Path(video_path).exists(): + raise ValueError(f"Reference video path '{video_path}' does not exist") + + return v + + @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 + + if width % self.reference_downscale_factor != 0: + raise ValueError( + f"Width {width} is not evenly divisible by reference_downscale_factor " + f"{self.reference_downscale_factor}. Choose a downscale factor that divides {width} evenly." + ) + if height % self.reference_downscale_factor != 0: + raise ValueError( + f"Height {height} is not evenly divisible by reference_downscale_factor " + f"{self.reference_downscale_factor}. Choose a downscale factor that divides {height} evenly." + ) + + scaled_width = width // self.reference_downscale_factor + scaled_height = height // self.reference_downscale_factor + + if scaled_width % 32 != 0: + raise ValueError( + f"Scaled reference width {scaled_width} (from {width} / {self.reference_downscale_factor}) " + f"is not divisible by 32. Choose a different downscale factor or adjust video_dims." + ) + if scaled_height % 32 != 0: + raise ValueError( + f"Scaled reference height {scaled_height} (from {height} / {self.reference_downscale_factor}) " + f"is not divisible by 32. Choose a different downscale factor or adjust video_dims." + ) + + 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""" + + interval: int | None = Field( + default=None, + description="Number of steps between checkpoint saves. If None, intermediate checkpoints are disabled.", + gt=0, + ) + + keep_last_n: int = Field( + default=1, + description="Number of most recent checkpoints to keep. Set to -1 to keep all checkpoints.", + ge=-1, + ) + + precision: Literal["bfloat16", "float32"] = Field( + default="bfloat16", + description="Precision to use when saving checkpoint weights. Options: 'bfloat16' or 'float32'.", + ) + + no_resume: bool = Field( + default=False, + description="When True, ignore any saved training state and start from step 0. " + "Model weights from load_checkpoint are still loaded, but optimizer/scheduler " + "state and step counter are reset.", + ) + + save_training_state: Literal["full", "minimal", "off"] = Field( + default="minimal", + description="Save training state alongside checkpoints for resume. " + "'full': optimizer + scheduler + RNG + step (~800MB for LoRA, much larger for full fine-tuning). " + "'minimal': scheduler + RNG + step only (~few KB, sufficient for LoRA). " + "'off': nothing saved, resume not possible.", + ) + + +class HubConfig(ConfigBaseModel): + """Configuration for Hugging Face Hub integration""" + + push_to_hub: bool = Field(default=False, description="Whether to push the model weights to the Hugging Face Hub") + hub_model_id: str | None = Field( + default=None, description="Hugging Face Hub repository ID (e.g., 'username/repo-name')" + ) + + @model_validator(mode="after") + def validate_hub_config(self) -> "HubConfig": + """Validate that hub_model_id is not None when push_to_hub is True.""" + if self.push_to_hub and not self.hub_model_id: + raise ValueError("hub_model_id must be specified when push_to_hub is True") + return self + + +class WandbConfig(ConfigBaseModel): + """Configuration for Weights & Biases logging""" + + enabled: bool = Field( + default=False, + description="Whether to enable W&B logging", + ) + + project: str = Field( + default="ltxv-trainer", + description="W&B project name", + ) + + entity: str | None = Field( + default=None, + description="W&B username or team", + ) + + tags: list[str] = Field( + default_factory=list, + description="Tags to add to the W&B run", + ) + + log_validation_videos: bool = Field( + default=True, + description="Whether to log validation videos to W&B", + ) + + +class FlowMatchingConfig(ConfigBaseModel): + """Configuration for flow matching training""" + + timestep_sampling_mode: Literal["uniform", "shifted_logit_normal"] = Field( + default="shifted_logit_normal", + description="Mode to use for timestep sampling", + ) + + timestep_sampling_params: dict = Field( + default_factory=dict, + description="Parameters for timestep sampling", + ) + + +class LtxTrainerConfig(ConfigBaseModel): + """Unified configuration for LTXV training""" + + # Sub-configurations + model: ModelConfig = Field(default_factory=ModelConfig) + lora: LoraConfig | None = Field(default=None) + training_strategy: TrainingStrategyConfig = Field( + default_factory=TextToVideoConfig, + description="Training strategy configuration. Determines the training mode and its parameters.", + ) + optimization: OptimizationConfig = Field(default_factory=OptimizationConfig) + acceleration: AccelerationConfig = Field(default_factory=AccelerationConfig) + data: DataConfig + validation: ValidationConfig = Field(default_factory=ValidationConfig) + checkpoints: CheckpointsConfig = Field(default_factory=CheckpointsConfig) + hub: HubConfig = Field(default_factory=HubConfig) + flow_matching: FlowMatchingConfig = Field(default_factory=FlowMatchingConfig) + wandb: WandbConfig = Field(default_factory=WandbConfig) + + # General configuration + seed: int = Field( + default=42, + description="Random seed for reproducibility", + ) + + output_dir: str = Field( + default="outputs", + description="Directory to save model outputs", + ) + + # noinspection PyNestedDecorators + @field_validator("output_dir") + @classmethod + def expand_output_path(cls, v: str) -> str: + """Expand user home directory in output path.""" + return str(Path(v).expanduser().resolve()) + + 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: + 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: + raise ValueError("LoRA configuration must be provided when training_mode is 'lora'") + + # Check that LoRA config is provided when using video_to_video strategy + if self.training_strategy.name == "video_to_video" and self.model.training_mode != "lora": + raise ValueError("Training mode must be 'lora' when using video_to_video strategy") + + return self diff --git a/packages/ltx-trainer/src/ltx_trainer/config_display.py b/packages/ltx-trainer/src/ltx_trainer/config_display.py new file mode 100644 index 0000000000000000000000000000000000000000..c9ddb2070e19625258a0e2bd91ee32fd977623b5 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/config_display.py @@ -0,0 +1,156 @@ +"""Display utilities for training configuration. +This module provides formatted console output for LtxTrainerConfig. +""" + +from rich import box +from rich.console import Console +from rich.table import Table + +from ltx_trainer.config import LtxTrainerConfig + + +def print_config(config: LtxTrainerConfig) -> None: + """Print configuration as a nicely formatted table with sections.""" + + def fmt(v: object, max_len: int = 55) -> str: + """Format any value for display.""" + if v is None: + return "[dim]—[/]" + if isinstance(v, bool): + return "[green]✓[/]" if v else "[dim]✗[/]" + if isinstance(v, (list, tuple)): + if not v: + return "[dim]—[/]" + return ", ".join(str(x) for x in v) + s = str(v) + return s[: max_len - 3] + "..." if len(s) > max_len else s + + cfg = config + opt = cfg.optimization + val = cfg.validation + accel = cfg.acceleration + + # Build sections: list of (section_title, [(key, value), ...]) + sections: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "🎬 Model", + [ + ("Base", fmt(cfg.model.model_path)), + ("Text Encoder", fmt(cfg.model.text_encoder_path) or "[dim]Built-in[/]"), + ("Training Mode", f"[bold green]{cfg.model.training_mode.upper()}[/]"), + ("Load Checkpoint", fmt(cfg.model.load_checkpoint) if cfg.model.load_checkpoint else "[dim]—[/]"), + ], + ), + ] + + if cfg.lora: + sections.append( + ( + "🔗 LoRA", + [ + ("Rank / Alpha", f"{cfg.lora.rank} / {cfg.lora.alpha}"), + ("Dropout", str(cfg.lora.dropout)), + ("Target Modules", fmt(cfg.lora.target_modules)), + ], + ) + ) + + # Strategy section - include strategy-specific fields + strategy_items: list[tuple[str, str]] = [("Name", cfg.training_strategy.name)] + if hasattr(cfg.training_strategy, "with_audio"): + strategy_items.append(("Audio", fmt(cfg.training_strategy.with_audio))) + if hasattr(cfg.training_strategy, "first_frame_conditioning_p"): + strategy_items.append(("First Frame Cond P", str(cfg.training_strategy.first_frame_conditioning_p))) + + sections.append(("🎯 Strategy", strategy_items)) + + sections.extend( + [ + ( + "⚡ Optimization", + [ + ("Steps", f"[bold]{opt.steps:,}[/]"), + ("Learning Rate", f"{opt.learning_rate:.2e}"), + ("Batch Size", str(opt.batch_size)), + ("Grad Accumulation", str(opt.gradient_accumulation_steps)), + ("Optimizer", opt.optimizer_type), + ("Scheduler", opt.scheduler_type), + ("Max Grad Norm", str(opt.max_grad_norm)), + ("Grad Checkpointing", fmt(opt.enable_gradient_checkpointing)), + ], + ), + ( + "🚀 Acceleration", + [ + ("Mixed Precision", accel.mixed_precision_mode or "[dim]—[/]"), + ("Quantization", str(accel.quantization) if accel.quantization else "[dim]—[/]"), + ("Text Encoder 8bit", fmt(accel.load_text_encoder_in_8bit)), + ("Optimizer CPU Offload", fmt(accel.offload_optimizer_during_validation)), + ], + ), + ( + "🎥 Validation", + [ + ("Prompts", f"{len(val.prompts)} prompt(s)" if val.prompts else "[dim]—[/]"), + ("Interval", f"Every {val.interval} steps" if val.interval else "[dim]Disabled[/]"), + ("Video Dims", f"{val.video_dims[0]}x{val.video_dims[1]}, {val.video_dims[2]} frames"), + ("Frame Rate", f"{val.frame_rate} fps"), + ("Inference Steps", str(val.inference_steps)), + ("CFG Scale", str(val.guidance_scale)), + ( + "STG", + f"scale={val.stg_scale}; blocks={fmt(val.stg_blocks)}; mode={val.stg_mode}" + if val.stg_scale > 0 + else "[dim]Disabled[/]", + ), + ("Seed", str(val.seed)), + ], + ), + ( + "📂 Data & Output", + [ + ("Dataset", fmt(cfg.data.preprocessed_data_root)), + ("Dataloader Workers", str(cfg.data.num_dataloader_workers)), + ("Output Dir", fmt(cfg.output_dir)), + ("Seed", str(cfg.seed)), + ], + ), + ( + "🔌 Integrations", + [ + ( + "Checkpoints", + f"Every {cfg.checkpoints.interval} steps (keep {cfg.checkpoints.keep_last_n})" + if cfg.checkpoints.interval + else "[dim]Disabled[/]", + ), + ("W&B", f"{cfg.wandb.project}" if cfg.wandb.enabled else "[dim]Disabled[/]"), + ("HF Hub", cfg.hub.hub_model_id if cfg.hub.push_to_hub else "[dim]Disabled[/]"), + ], + ), + ] + ) + + # Build table with section headers + table = Table( + title="[bold]⚙️ Training Configuration[/]", + show_header=False, + box=box.ROUNDED, + border_style="bright_blue", + padding=(0, 1), + title_style="bold bright_blue", + ) + table.add_column("Key", style="white", width=20) + table.add_column("Value", style="cyan") + + for i, (section_title, items) in enumerate(sections): + if i > 0: + table.add_row("", "") # Blank line between sections + table.add_row(f"[bold yellow]{section_title}[/]", "") + for key, value in items: + table.add_row(f" {key}", value) + + console = Console() + console.print() + console.print(table) + console.print() diff --git a/packages/ltx-trainer/src/ltx_trainer/datasets.py b/packages/ltx-trainer/src/ltx_trainer/datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..2d8f59cb9d1142a967f01bc63ea6dcb96361c3ae --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/datasets.py @@ -0,0 +1,315 @@ +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import torch +from einops import rearrange +from torch import Tensor +from torch.utils.data import Dataset + +from ltx_trainer import logger + +# Constants for precomputed data directories +PRECOMPUTED_DIR_NAME = ".precomputed" + + +class DummyDataset(Dataset): + """Produce random latents and prompt embeddings. For minimal demonstration and benchmarking purposes""" + + def __init__( + self, + width: int = 1024, + height: int = 1024, + num_frames: int = 25, + fps: int = 24, + dataset_length: int = 200, + latent_dim: int = 128, + latent_spatial_compression_ratio: int = 32, + latent_temporal_compression_ratio: int = 8, + prompt_embed_dim: int = 4096, + prompt_sequence_length: int = 256, + ) -> None: + if width % 32 != 0: + raise ValueError(f"Width must be divisible by 32, got {width=}") + + if height % 32 != 0: + raise ValueError(f"Height must be divisible by 32, got {height=}") + + if num_frames % 8 != 1: + raise ValueError(f"Number of frames must have a remainder of 1 when divided by 8, got {num_frames=}") + + self.width = width + self.height = height + self.num_frames = num_frames + self.fps = fps + self.dataset_length = dataset_length + self.latent_dim = latent_dim + self.num_latent_frames = (num_frames - 1) // latent_temporal_compression_ratio + 1 + self.latent_height = height // latent_spatial_compression_ratio + self.latent_width = width // latent_spatial_compression_ratio + self.latent_sequence_length = self.num_latent_frames * self.latent_height * self.latent_width + self.prompt_embed_dim = prompt_embed_dim + self.prompt_sequence_length = prompt_sequence_length + + def __len__(self) -> int: + return self.dataset_length + + def __getitem__(self, idx: int) -> dict[str, dict[str, Tensor]]: + return { + "latent_conditions": { + "latents": torch.randn( + self.latent_dim, + self.num_latent_frames, + self.latent_height, + self.latent_width, + ), + "num_frames": self.num_latent_frames, + "height": self.latent_height, + "width": self.latent_width, + "fps": self.fps, + }, + "text_conditions": { + "video_prompt_embeds": torch.randn( + self.prompt_sequence_length, + self.prompt_embed_dim, + ), + "audio_prompt_embeds": torch.randn( + self.prompt_sequence_length, + self.prompt_embed_dim, + ), + "prompt_attention_mask": torch.ones( + self.prompt_sequence_length, + dtype=torch.bool, + ), + }, + } + + +class PrecomputedDataset(Dataset): + def __init__(self, data_root: str, data_sources: dict[str, str] | list[str] | None = None) -> None: + """ + Generic dataset for loading precomputed data from multiple sources. + Args: + data_root: Root directory containing preprocessed data + data_sources: Either: + - Dict mapping directory names to output keys + - List of directory names (keys will equal values) + - None (defaults to ["latents", "conditions"]) + Example: + # Standard mode (list) + dataset = PrecomputedDataset("data/", ["latents", "conditions"]) + # Standard mode (dict) + dataset = PrecomputedDataset("data/", {"latents": "latent_conditions", "conditions": "text_conditions"}) + # IC-LoRA mode + dataset = PrecomputedDataset("data/", ["latents", "conditions", "reference_latents"]) + Note: + Latents are always returned in non-patchified format [C, F, H, W]. + Legacy patchified format [seq_len, C] is automatically converted. + """ + super().__init__() + + self.data_root = self._setup_data_root(data_root) + self.data_sources = self._normalize_data_sources(data_sources) + self.source_paths = self._setup_source_paths() + self.sample_files = self._discover_samples() + self._validate_setup() + + @staticmethod + def _setup_data_root(data_root: str) -> Path: + """Setup and validate the data root directory.""" + data_root = Path(data_root).expanduser().resolve() + + if not data_root.exists(): + raise FileNotFoundError(f"Data root directory does not exist: {data_root}") + + # If the given path is the dataset root, use the precomputed subdirectory + if (data_root / PRECOMPUTED_DIR_NAME).exists(): + data_root = data_root / PRECOMPUTED_DIR_NAME + + return data_root + + @staticmethod + def _normalize_data_sources(data_sources: dict[str, str] | list[str] | None) -> dict[str, str]: + """Normalize data_sources input to a consistent dict format.""" + if data_sources is None: + # Default sources + return {"latents": "latent_conditions", "conditions": "text_conditions"} + elif isinstance(data_sources, list): + # Convert list to dict where keys equal values + return {source: source for source in data_sources} + elif isinstance(data_sources, dict): + return data_sources.copy() + else: + raise TypeError(f"data_sources must be dict, list, or None, got {type(data_sources)}") + + def _setup_source_paths(self) -> dict[str, Path]: + """Map data source names to their actual directory paths.""" + source_paths = {} + + for dir_name in self.data_sources: + source_path = self.data_root / dir_name + source_paths[dir_name] = source_path + + # Check that all sources exist. + if not source_path.exists(): + raise FileNotFoundError(f"Required {dir_name} directory does not exist: {source_path}") + + return source_paths + + def _discover_samples(self) -> dict[str, list[Path]]: + """Discover all valid sample files across all data sources. + Uses a fast two-pass approach: first globs all sources in parallel to build + full-path sets in memory, then checks expected paths via set membership. + This avoids O(N * num_sources) stat calls on networked filesystems while + correctly handling path remapping (e.g. latent_X.pt -> condition_X.pt). + """ + if not self.data_sources: + raise ValueError("No data sources configured") + + data_key = "latents" if "latents" in self.data_sources else next(iter(self.data_sources.keys())) + data_path = self.source_paths[data_key] + + # Pass 1: Glob all sources in parallel, build full-path sets + def _glob_source(dir_name: str) -> tuple[list[Path], set[str]]: + source_path = self.source_paths[dir_name] + paths = list(source_path.glob("**/*.pt")) + path_set = {str(p) for p in paths} + return paths, path_set + + with ThreadPoolExecutor(max_workers=len(self.data_sources)) as executor: + glob_results = dict( + zip( + self.data_sources.keys(), + executor.map(_glob_source, self.data_sources.keys()), + strict=True, + ) + ) + + # Get primary source files (cached from glob, no second scan) + data_files, _ = glob_results[data_key] + if not data_files: + raise ValueError(f"No data files found in {data_path}") + data_files.sort() + + # Log source sizes + for dir_name, (paths, _) in glob_results.items(): + logger.debug(f"Source {dir_name}: {len(paths)} files") + + # Build path sets for non-primary sources + other_path_sets = { + dir_name: path_set for dir_name, (_, path_set) in glob_results.items() if dir_name != data_key + } + + # Pass 2: For each primary file, check if expected paths exist in other sources' sets + sample_files: dict[str, list[Path]] = {output_key: [] for output_key in self.data_sources.values()} + valid_count = 0 + + for data_file in data_files: + rel_path = data_file.relative_to(data_path) + + # Check all other sources via set lookup (O(1) per source, no stat calls) + all_exist = True + for dir_name, path_set in other_path_sets.items(): + expected = self._get_expected_file_path(dir_name, data_file, rel_path) + if str(expected) not in path_set: + logger.debug(f"Skipping {data_file.name}: no matching {dir_name} file at {expected}") + all_exist = False + break + + if all_exist: + self._fill_sample_data_files(data_file, rel_path, sample_files) + valid_count += 1 + + skipped = len(data_files) - valid_count + if skipped > 0: + logger.info(f"Fast index: {valid_count} valid samples from {len(data_files)} total ({skipped} skipped)") + else: + logger.debug(f"Fast index: {valid_count} valid samples from {len(data_files)} total") + + return sample_files + + def _get_expected_file_path(self, dir_name: str, data_file: Path, rel_path: Path) -> Path: + """Get the expected file path for a given data source.""" + source_path = self.source_paths[dir_name] + + # For conditions, handle legacy naming where latent_X.pt maps to condition_X.pt + if dir_name == "conditions" and data_file.name.startswith("latent_"): + return source_path / f"condition_{data_file.stem[7:]}.pt" + + return source_path / rel_path + + def _fill_sample_data_files(self, data_file: Path, rel_path: Path, sample_files: dict[str, list[Path]]) -> None: + """Add a valid sample to the sample_files tracking.""" + for dir_name, output_key in self.data_sources.items(): + expected_path = self._get_expected_file_path(dir_name, data_file, rel_path) + sample_files[output_key].append(expected_path.relative_to(self.source_paths[dir_name])) + + def _validate_setup(self) -> None: + """Validate that the dataset setup is correct.""" + sample_counts = {key: len(files) for key, files in self.sample_files.items()} + if not sample_counts or all(count == 0 for count in sample_counts.values()): + raise ValueError( + f"No valid samples found in {self.data_root} - all configured data sources " + f"({list(self.data_sources)}) must have matching files (per-source counts: {sample_counts})" + ) + + # Verify all output keys have the same number of samples + if len(set(sample_counts.values())) > 1: + raise ValueError(f"Mismatched sample counts across sources: {sample_counts}") + + def __len__(self) -> int: + # Use the first output key as reference count + first_key = next(iter(self.sample_files.keys())) + return len(self.sample_files[first_key]) + + def __getitem__(self, index: int) -> dict[str, torch.Tensor]: + result = {} + + for dir_name, output_key in self.data_sources.items(): + source_path = self.source_paths[dir_name] + file_rel_path = self.sample_files[output_key][index] + file_path = source_path / file_rel_path + + try: + data = torch.load(file_path, map_location="cpu", weights_only=True) + + # Normalize video latent format if this is a latent source + if "latent" in dir_name.lower(): + data = self._normalize_video_latents(data) + + result[output_key] = data + except Exception as e: + raise RuntimeError(f"Failed to load {output_key} from {file_path}: {e}") from e + + # Add index for debugging + result["idx"] = index + return result + + @staticmethod + def _normalize_video_latents(data: dict) -> dict: + """ + Normalize video latents to non-patchified format [C, F, H, W]. + Used for keeping backward compatibility with legacy datasets. + """ + latents = data["latents"] + + # Check if latents are in legacy patchified format [seq_len, C] + if latents.dim() == 2: + # Legacy format: [seq_len, C] where seq_len = F * H * W + num_frames = data["num_frames"] + height = data["height"] + width = data["width"] + + # Unpatchify: [seq_len, C] -> [C, F, H, W] + latents = rearrange( + latents, + "(f h w) c -> c f h w", + f=num_frames, + h=height, + w=width, + ) + + # Update the data dict with unpatchified latents + data = data.copy() + data["latents"] = latents + + return data diff --git a/packages/ltx-trainer/src/ltx_trainer/gemma_8bit.py b/packages/ltx-trainer/src/ltx_trainer/gemma_8bit.py new file mode 100644 index 0000000000000000000000000000000000000000..83727907cea391c4f6b7df638b9b02761764eb45 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/gemma_8bit.py @@ -0,0 +1,104 @@ +# ruff: noqa: PLC0415 + +""" +8-bit Gemma text encoder loading utilities. +This module provides functionality for loading the Gemma text encoder in 8-bit precision +using bitsandbytes, which significantly reduces GPU memory usage. +Example usage: + from ltx_trainer.gemma_8bit import load_8bit_gemma + text_encoder = load_8bit_gemma(gemma_model_path="/path/to/gemma") +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + +import torch + +from ltx_core.text_encoders.gemma.encoders.base_encoder import GemmaTextEncoder +from ltx_core.text_encoders.gemma.tokenizer import LTXVGemmaTokenizer + + +def load_8bit_gemma( + gemma_model_path: str | Path, + dtype: torch.dtype = torch.bfloat16, + device: torch.device | str | int | None = None, +) -> GemmaTextEncoder: + """Load the Gemma text encoder in 8-bit precision using bitsandbytes. + Only the Gemma LLM backbone is loaded here. The embeddings processor + (feature extractor + connectors) should be loaded separately via + :func:`ltx_trainer.model_loader.load_embeddings_processor`. + Args: + gemma_model_path: Path to Gemma model directory + dtype: Data type for non-quantized model weights + device: Device to place the quantized model on. When ``None`` (default), + the device is inferred from ``LOCAL_RANK`` if CUDA is available, so + multi-process launches put each rank's encoder on its own GPU + instead of all colliding on ``cuda:0``. + Returns: + GemmaTextEncoder with 8-bit quantized Gemma backbone + Raises: + ImportError: If bitsandbytes is not installed + FileNotFoundError: If required model files are not found + """ + try: + from transformers import BitsAndBytesConfig, Gemma3ForConditionalGeneration + except ImportError as e: + raise ImportError( + "8-bit text encoder loading requires bitsandbytes. Install it with: uv pip install bitsandbytes" + ) from e + + gemma_path = _find_gemma_subpath(gemma_model_path, "model*.safetensors") + tokenizer_path = _find_gemma_subpath(gemma_model_path, "tokenizer.model") + + # Pin the entire model to a single device. `device_map="auto"` collides on cuda:0 + # in multi-process launches because every rank picks the same default device. + device_map: str | dict[str, int | str | torch.device] + if device is not None: + device_map = {"": device} + elif torch.cuda.is_available(): + device_map = {"": int(os.environ.get("LOCAL_RANK", "0"))} + else: + device_map = "auto" + + quantization_config = BitsAndBytesConfig(load_in_8bit=True) + with _suppress_accelerate_memory_warnings(): + gemma_model = Gemma3ForConditionalGeneration.from_pretrained( + gemma_path, + quantization_config=quantization_config, + torch_dtype=torch.bfloat16, + device_map=device_map, + local_files_only=True, + ) + + tokenizer = LTXVGemmaTokenizer(tokenizer_path, 1024) + + return GemmaTextEncoder( + tokenizer=tokenizer, + model=gemma_model, + dtype=dtype, + ) + + +def _find_gemma_subpath(root_path: str | Path, pattern: str) -> str: + """Find a file matching a glob pattern and return its parent directory.""" + matches = list(Path(root_path).rglob(pattern)) + if not matches: + raise FileNotFoundError(f"No files matching pattern '{pattern}' found under {root_path}") + return str(matches[0].parent) + + +@contextmanager +def _suppress_accelerate_memory_warnings() -> Generator[None, None, None]: + """Temporarily suppress INFO warnings from accelerate about memory allocation.""" + accelerate_logger = logging.getLogger("accelerate.utils.modeling") + old_level = accelerate_logger.level + accelerate_logger.setLevel(logging.WARNING) + try: + yield + finally: + accelerate_logger.setLevel(old_level) diff --git a/packages/ltx-trainer/src/ltx_trainer/gpu_utils.py b/packages/ltx-trainer/src/ltx_trainer/gpu_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..de385a1cefcadf504b537583fd2e180c5541f121 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/gpu_utils.py @@ -0,0 +1,90 @@ +"""GPU memory management utilities for training and inference.""" + +import functools +import gc +import subprocess +from typing import Callable, TypeVar + +import torch + +from ltx_trainer import logger + +F = TypeVar("F", bound=Callable) + + +def free_gpu_memory(log: bool = False) -> None: + """Free GPU memory by running garbage collection and emptying CUDA cache. + Args: + log: If True, log memory stats after clearing + """ + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + if log: + allocated = torch.cuda.memory_allocated() / 1024**3 + reserved = torch.cuda.memory_reserved() / 1024**3 + logger.debug(f"GPU memory freed. Allocated: {allocated:.2f}GB, Reserved: {reserved:.2f}GB") + + +class free_gpu_memory_context: # noqa: N801 + """Context manager and decorator to free GPU memory before and/or after execution. + Can be used as a decorator: + @free_gpu_memory_context(after=True) + def my_function(): + ... + Or as a context manager: + with free_gpu_memory_context(): + heavy_operation() + Args: + before: Free memory before execution (default: False) + after: Free memory after execution (default: True) + log: Log memory stats when freeing (default: False) + """ + + def __init__(self, *, before: bool = False, after: bool = True, log: bool = False) -> None: + self.before = before + self.after = after + self.log = log + + def __enter__(self) -> "free_gpu_memory_context": + if self.before: + free_gpu_memory(log=self.log) + return self + + def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None: + if self.after: + free_gpu_memory(log=self.log) + + def __call__(self, func: F) -> F: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> object: + with self: + return func(*args, **kwargs) + + return wrapper # type: ignore + + +def get_gpu_memory_gb(device: torch.device) -> float: + """Get current GPU memory usage in GB using nvidia-smi. + Args: + device: torch.device to get memory usage for + Returns: + Current GPU memory usage in GB + """ + try: + device_id = device.index if device.index is not None else 0 + result = subprocess.check_output( + [ + "nvidia-smi", + "--query-gpu=memory.used", + "--format=csv,nounits,noheader", + "-i", + str(device_id), + ], + encoding="utf-8", + ) + return float(result.strip()) / 1024 # Convert MB to GB + except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e: + logger.error(f"Failed to get GPU memory from nvidia-smi: {e}") + # Fallback to torch + return torch.cuda.memory_allocated(device) / 1024**3 diff --git a/packages/ltx-trainer/src/ltx_trainer/hf_hub_utils.py b/packages/ltx-trainer/src/ltx_trainer/hf_hub_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..32f9a998d9a5e8d13b7efbeb410a39781c537bb7 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/hf_hub_utils.py @@ -0,0 +1,212 @@ +import shutil +import tempfile +from pathlib import Path +from typing import List, Optional, Union + +import imageio +from huggingface_hub import HfApi, create_repo +from huggingface_hub.utils import are_progress_bars_disabled, disable_progress_bars, enable_progress_bars +from rich.progress import Progress, SpinnerColumn, TextColumn + +from ltx_trainer import logger +from ltx_trainer.config import LtxTrainerConfig + + +def push_to_hub( + weights_path: Path, + sampled_videos_paths: Optional[List[Path]], + config: LtxTrainerConfig, +) -> None: + """Push the trained LoRA weights to HuggingFace Hub.""" + if not config.hub.hub_model_id: + logger.warning("⚠️ HuggingFace hub_model_id not specified, skipping push to hub") + return + + api = HfApi() + + # Save original progress bar state + original_progress_state = are_progress_bars_disabled() + disable_progress_bars() # Disable during our custom progress tracking + + try: + # Try to create repo if it doesn't exist + try: + repo = create_repo( + repo_id=config.hub.hub_model_id, + repo_type="model", + exist_ok=True, # Don't raise error if repo exists + ) + repo_id = repo.repo_id + logger.info(f"🤗 Successfully created HuggingFace model repository at: {repo.url}") + except Exception as e: + logger.error(f"❌ Failed to create HuggingFace model repository: {e}") + return + + # Create a single temporary directory for all files + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + try: + # Copy weights + task_copy = progress.add_task("Copying weights...", total=None) + weights_dest = temp_path / weights_path.name + shutil.copy2(weights_path, weights_dest) + progress.update(task_copy, description="✓ Weights copied") + + # Create model card and save samples + task_card = progress.add_task("Creating model card and samples...", total=None) + _create_model_card( + output_dir=temp_path, + videos=sampled_videos_paths, + config=config, + ) + progress.update(task_card, description="✓ Model card and samples created") + + # Upload everything at once + task_upload = progress.add_task("Pushing files to HuggingFace Hub...", total=None) + api.upload_folder( + folder_path=str(temp_path), + repo_id=repo_id, + repo_type="model", + ) + progress.update(task_upload, description="✓ Files pushed to HuggingFace Hub") + logger.info("✅ Successfully pushed files to HuggingFace Hub") + + except Exception as e: + logger.error(f"❌ Failed to process and push files to HuggingFace Hub: {e}") + raise # Re-raise to handle in outer try block + + finally: + # Restore original progress bar state + if not original_progress_state: + enable_progress_bars() + + +def convert_video_to_gif(video_path: Path, output_path: Path) -> None: + """Convert a video file to GIF format.""" + try: + # Read the video file + reader = imageio.get_reader(str(video_path)) + fps = reader.get_meta_data()["fps"] + + # Write GIF file with infinite loop + writer = imageio.get_writer( + str(output_path), + fps=min(fps, 15), # Cap FPS at 15 for reasonable file size + loop=0, # 0 means infinite loop + ) + + for frame in reader: + writer.append_data(frame) + + writer.close() + reader.close() + except Exception as e: + logger.error(f"Failed to convert video to GIF: {e}") + + +def _create_model_card( + output_dir: Union[str, Path], + videos: Optional[List[Path]], + config: LtxTrainerConfig, +) -> Path: + """Generate and save a model card for the trained model.""" + + repo_id = config.hub.hub_model_id + pretrained_model_name_or_path = config.model.model_path + validation_prompts = config.validation.prompts + output_dir = Path(output_dir) + template_path = Path(__file__).parent.parent.parent / "templates" / "model_card.md" + + # Read the template + template = template_path.read_text() + + # Get model name from repo_id + model_name = repo_id.split("/")[-1] + + # Get base model information + base_model_link = str(pretrained_model_name_or_path) + model_path_str = str(pretrained_model_name_or_path) + is_url = model_path_str.startswith(("http://", "https://")) + + # For URLs, extract the filename from the URL. For local paths, use the filename stem + base_model_name = model_path_str.split("/")[-1] if is_url else Path(pretrained_model_name_or_path).name + + # Format validation prompts and create grid layout + prompts_text = "" + sample_grid = [] + + if validation_prompts and videos: + prompts_text = "Example prompts used during validation:\n\n" + + # Create samples directory + samples_dir = output_dir / "samples" + samples_dir.mkdir(exist_ok=True, parents=True) + + # Process videos and create cells + cells = [] + for i, (prompt, video) in enumerate(zip(validation_prompts, videos, strict=False)): + if video.exists(): + # Add prompt to text section + prompts_text += f"- `{prompt}`\n" + + # Convert video to GIF + gif_path = samples_dir / f"sample_{i}.gif" + try: + convert_video_to_gif(video, gif_path) + + # Create grid cell with collapsible description + cell = ( + f"![example{i + 1}](./samples/sample_{i}.gif)" + "
" + '
' + f"Prompt" + f"{prompt}" + "
" + ) + cells.append(cell) + except Exception as e: + logger.error(f"Failed to process video {video}: {e}") + + # Calculate optimal grid dimensions + num_cells = len(cells) + if num_cells > 0: + # Aim for a roughly square grid, with max 4 columns + num_cols = min(4, num_cells) + num_rows = (num_cells + num_cols - 1) // num_cols # Ceiling division + + # Create grid rows + for row in range(num_rows): + start_idx = row * num_cols + end_idx = min(start_idx + num_cols, num_cells) + row_cells = cells[start_idx:end_idx] + # Properly format the row with table markers and exact number of cells + formatted_row = "| " + " | ".join(row_cells) + " |" + sample_grid.append(formatted_row) + + # Join grid rows with just the content, no headers needed + grid_text = "\n".join(sample_grid) if sample_grid else "" + + # Fill in the template + model_card_content = template.format( + base_model=base_model_name, + base_model_link=base_model_link, + model_name=model_name, + training_type="LoRA fine-tuning" if config.model.training_mode == "lora" else "Full model fine-tuning", + training_steps=config.optimization.steps, + learning_rate=config.optimization.learning_rate, + batch_size=config.optimization.batch_size, + validation_prompts=prompts_text, + sample_grid=grid_text, + ) + + # Save the model card directly + model_card_path = output_dir / "README.md" + model_card_path.write_text(model_card_content) + + return model_card_path diff --git a/packages/ltx-trainer/src/ltx_trainer/model_loader.py b/packages/ltx-trainer/src/ltx_trainer/model_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..f6aeba62359144eb1d28372098df881b6688815e --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/model_loader.py @@ -0,0 +1,378 @@ +# ruff: noqa: PLC0415 + +""" +Model loader for LTX-2 trainer using the new ltx-core package. +This module provides a unified interface for loading LTX-2 model components +for training, using SingleGPUModelBuilder from ltx-core. +Example usage: + # Load individual components + vae_encoder = load_video_vae_encoder("/path/to/checkpoint.safetensors", device="cuda") + vae_decoder = load_video_vae_decoder("/path/to/checkpoint.safetensors", device="cuda") + text_encoder = load_text_encoder("/path/to/gemma", device="cuda") + # Load all components at once + components = load_model("/path/to/checkpoint.safetensors", text_encoder_path="/path/to/gemma") +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import torch + +from ltx_trainer import logger + +# Type alias for device specification +Device = str | torch.device + +# Type checking imports (not loaded at runtime) +if TYPE_CHECKING: + from ltx_core.components.schedulers import LTX2Scheduler + from ltx_core.model.audio_vae import AudioDecoder, AudioEncoder, Vocoder + from ltx_core.model.transformer import LTXModel + from ltx_core.model.video_vae import VideoDecoder, VideoEncoder + from ltx_core.text_encoders.gemma import GemmaTextEncoder + from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessor + + +def _to_torch_device(device: Device) -> torch.device: + """Convert device specification to torch.device.""" + return torch.device(device) if isinstance(device, str) else device + + +# ============================================================================= +# Individual Component Loaders +# ============================================================================= + + +def load_transformer( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "LTXModel": + """Load the LTX transformer model. + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded LTXModel transformer + """ + from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder + from ltx_core.model.transformer.model_configurator import ( + LTXV_MODEL_COMFY_RENAMING_MAP, + LTXModelConfigurator, + ) + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=LTXModelConfigurator, + model_sd_ops=LTXV_MODEL_COMFY_RENAMING_MAP, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_video_vae_encoder( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "VideoEncoder": + """Load the video VAE encoder (for preprocessing). + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded VideoEncoder + """ + from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder + from ltx_core.model.video_vae import VAE_ENCODER_COMFY_KEYS_FILTER, VideoEncoderConfigurator + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=VideoEncoderConfigurator, + model_sd_ops=VAE_ENCODER_COMFY_KEYS_FILTER, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_video_vae_decoder( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "VideoDecoder": + """Load the video VAE decoder (for inference/validation). + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded VideoDecoder + """ + from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder + from ltx_core.model.video_vae import VAE_DECODER_COMFY_KEYS_FILTER, VideoDecoderConfigurator + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=VideoDecoderConfigurator, + model_sd_ops=VAE_DECODER_COMFY_KEYS_FILTER, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_audio_vae_encoder( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "AudioEncoder": + """Load the audio VAE encoder (for preprocessing). + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights (default bfloat16, but float32 recommended for quality) + Returns: + Loaded AudioEncoder + """ + from ltx_core.loader import SingleGPUModelBuilder + from ltx_core.model.audio_vae import AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER, AudioEncoderConfigurator + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=AudioEncoderConfigurator, + model_sd_ops=AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_audio_vae_decoder( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "AudioDecoder": + """Load the audio VAE decoder. + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded AudioDecoder + """ + from ltx_core.loader import SingleGPUModelBuilder + from ltx_core.model.audio_vae import AUDIO_VAE_DECODER_COMFY_KEYS_FILTER, AudioDecoderConfigurator + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=AudioDecoderConfigurator, + model_sd_ops=AUDIO_VAE_DECODER_COMFY_KEYS_FILTER, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_vocoder( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "Vocoder": + """Load the vocoder (for audio waveform generation). + Args: + checkpoint_path: Path to the safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded Vocoder + """ + from ltx_core.loader import SingleGPUModelBuilder + from ltx_core.model.audio_vae import VOCODER_COMFY_KEYS_FILTER, VocoderConfigurator + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=VocoderConfigurator, + model_sd_ops=VOCODER_COMFY_KEYS_FILTER, + ).build(device=_to_torch_device(device), dtype=dtype) + + +def load_text_encoder( + gemma_model_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, + load_in_8bit: bool = False, +) -> "GemmaTextEncoder": + """Load the Gemma text encoder. + Args: + gemma_model_path: Path to Gemma model directory + device: Device to load model on + dtype: Data type for model weights + load_in_8bit: Whether to load the Gemma model in 8-bit precision using bitsandbytes. + Returns: + Loaded GemmaTextEncoder + """ + if not Path(gemma_model_path).is_dir(): + raise ValueError(f"Gemma model path is not a directory: {gemma_model_path}") + + # Use 8-bit loading path if requested + if load_in_8bit: + from ltx_trainer.gemma_8bit import load_8bit_gemma + + return load_8bit_gemma(gemma_model_path, dtype, device=device) + + # Standard loading path + from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder + from ltx_core.text_encoders.gemma import ( + GEMMA_LLM_KEY_OPS, + GEMMA_MODEL_OPS, + GemmaTextEncoderConfigurator, + module_ops_from_gemma_root, + ) + from ltx_core.utils import find_matching_file + + torch_device = _to_torch_device(device) + + gemma_model_folder = find_matching_file(str(gemma_model_path), "model*.safetensors").parent + gemma_weight_paths = [str(p) for p in gemma_model_folder.rglob("*.safetensors")] + + text_encoder = SingleGPUModelBuilder( + model_path=tuple(gemma_weight_paths), + model_class_configurator=GemmaTextEncoderConfigurator, + model_sd_ops=GEMMA_LLM_KEY_OPS, + module_ops=(GEMMA_MODEL_OPS, *module_ops_from_gemma_root(str(gemma_model_path))), + ).build(device=torch_device, dtype=dtype) + + return text_encoder + + +def load_embeddings_processor( + checkpoint_path: str | Path, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, +) -> "EmbeddingsProcessor": + """Load the embeddings processor (feature extractor + video/audio connectors). + Args: + checkpoint_path: Path to the LTX-2 safetensors checkpoint file + device: Device to load model on + dtype: Data type for model weights + Returns: + Loaded EmbeddingsProcessor with feature extractor and connectors + """ + from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder + from ltx_core.text_encoders.gemma import ( + EMBEDDINGS_PROCESSOR_KEY_OPS, + EmbeddingsProcessorConfigurator, + ) + + torch_device = _to_torch_device(device) + + return SingleGPUModelBuilder( + model_path=str(checkpoint_path), + model_class_configurator=EmbeddingsProcessorConfigurator, + model_sd_ops=EMBEDDINGS_PROCESSOR_KEY_OPS, + ).build(device=torch_device, dtype=dtype) + + +# ============================================================================= +# Combined Component Loader +# ============================================================================= + + +@dataclass +class LtxModelComponents: + """Container for all LTX-2 model components.""" + + transformer: "LTXModel" + video_vae_encoder: "VideoEncoder | None" = None + video_vae_decoder: "VideoDecoder | None" = None + audio_vae_decoder: "AudioDecoder | None" = None + vocoder: "Vocoder | None" = None + text_encoder: "GemmaTextEncoder | None" = None + scheduler: "LTX2Scheduler | None" = None + + +def load_model( + checkpoint_path: str | Path, + text_encoder_path: str | Path | None = None, + device: Device = "cpu", + dtype: torch.dtype = torch.bfloat16, + with_video_vae_encoder: bool = False, + with_video_vae_decoder: bool = True, + with_audio_vae_decoder: bool = True, + with_vocoder: bool = True, + with_text_encoder: bool = True, +) -> LtxModelComponents: + """ + Load LTX-2 model components from a safetensors checkpoint. + This is a convenience function that loads multiple components at once. + For loading individual components, use the dedicated functions: + - load_transformer() + - load_video_vae_encoder() + - load_video_vae_decoder() + - load_audio_vae_decoder() + - load_vocoder() + - load_text_encoder() + Args: + checkpoint_path: Path to the safetensors checkpoint file + text_encoder_path: Path to Gemma model directory (required if with_text_encoder=True) + device: Device to load models on ("cuda", "cpu", etc.) + dtype: Data type for model weights + with_video_vae_encoder: Whether to load the video VAE encoder (for preprocessing) + with_video_vae_decoder: Whether to load the video VAE decoder (for inference/validation) + with_audio_vae_decoder: Whether to load the audio VAE decoder + with_vocoder: Whether to load the vocoder + with_text_encoder: Whether to load the text encoder + Returns: + LtxModelComponents containing all loaded model components + """ + from ltx_core.components.schedulers import LTX2Scheduler + + checkpoint_path = Path(checkpoint_path) + + # Validate checkpoint exists + if not checkpoint_path.exists(): + raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}") + + logger.info(f"Loading LTX-2 model from {checkpoint_path}") + + torch_device = _to_torch_device(device) + + # Load transformer + logger.debug("Loading transformer...") + transformer = load_transformer(checkpoint_path, torch_device, dtype) + + # Load video VAE encoder + video_vae_encoder = None + if with_video_vae_encoder: + logger.debug("Loading video VAE encoder...") + video_vae_encoder = load_video_vae_encoder(checkpoint_path, torch_device, dtype) + + # Load video VAE decoder + video_vae_decoder = None + if with_video_vae_decoder: + logger.debug("Loading video VAE decoder...") + video_vae_decoder = load_video_vae_decoder(checkpoint_path, torch_device, dtype) + + # Load audio VAE decoder + audio_vae_decoder = None + if with_audio_vae_decoder: + logger.debug("Loading audio VAE decoder...") + audio_vae_decoder = load_audio_vae_decoder(checkpoint_path, torch_device, dtype) + + # Load vocoder + vocoder = None + if with_vocoder: + logger.debug("Loading vocoder...") + vocoder = load_vocoder(checkpoint_path, torch_device, dtype) + + # Load text encoder + text_encoder = None + if with_text_encoder: + if text_encoder_path is None: + raise ValueError("text_encoder_path must be provided when with_text_encoder=True") + logger.debug("Loading Gemma text encoder...") + text_encoder = load_text_encoder(text_encoder_path, torch_device, dtype) + + # Create scheduler (stateless, no loading needed) + scheduler = LTX2Scheduler() + + return LtxModelComponents( + transformer=transformer, + video_vae_encoder=video_vae_encoder, + video_vae_decoder=video_vae_decoder, + audio_vae_decoder=audio_vae_decoder, + vocoder=vocoder, + text_encoder=text_encoder, + scheduler=scheduler, + ) diff --git a/packages/ltx-trainer/src/ltx_trainer/progress.py b/packages/ltx-trainer/src/ltx_trainer/progress.py new file mode 100644 index 0000000000000000000000000000000000000000..114b2db57503890011b5335aa781d7609757657c --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/progress.py @@ -0,0 +1,182 @@ +"""Progress tracking for LTX training. +This module provides a unified progress display for training and validation sampling, +encapsulating all Rich progress bar logic in one place. +""" + +from rich.progress import ( + BarColumn, + Progress, + TaskID, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) + + +class SamplingContext: + """Context for validation sampling progress tracking. + Provides a unified progress display showing current video and denoising step. + Display format: "Sampling X/Y [████████████] step Z/W" + The progress bar shows the denoising progress for the current video. + """ + + def __init__(self, progress: Progress | None, task: TaskID | None, num_prompts: int, num_steps: int): + self._progress = progress + self._task = task + self._num_prompts = num_prompts + self._num_steps = num_steps + + def start_video(self, video_idx: int) -> None: + """Start tracking a new video (resets step progress).""" + if self._progress is None or self._task is None: + return + # Reset task for new video: completed=0, total=num_steps + self._progress.reset(self._task, total=self._num_steps) + self._progress.update( + self._task, + completed=0, + video=f"{video_idx + 1}/{self._num_prompts}", + info=f"step 0/{self._num_steps}", + ) + + def advance_step(self) -> None: + """Advance the denoising step by one.""" + if self._progress is None or self._task is None: + return + self._progress.advance(self._task) + completed = int(self._progress.tasks[self._task].completed) + self._progress.update(self._task, info=f"step {completed}/{self._num_steps}") + + def cleanup(self) -> None: + """Hide sampling task when done.""" + if self._progress is None or self._task is None: + return + self._progress.update(self._task, visible=False) + + +class TrainingProgress: + """Manages Rich progress display for training and validation. + This class encapsulates all progress bar logic, providing a clean interface + for the trainer to update progress without dealing with Rich internals. + Usage: + with TrainingProgress(enabled=True, total_steps=1000) as progress: + for step in range(1000): + # ... training step ... + progress.update_training(loss=0.1, lr=1e-4, step_time=0.5) + if should_validate: + sampling_ctx = progress.start_sampling(num_prompts=3, num_steps=30) + sampler = ValidationSampler(..., sampling_context=sampling_ctx) + for prompt_idx, prompt in enumerate(prompts): + sampling_ctx.start_video(prompt_idx) + sampler.generate(...) + sampling_ctx.cleanup() + """ + + def __init__(self, enabled: bool, total_steps: int): + """Initialize progress tracking. + Args: + enabled: Whether to display progress bars (False for non-main processes) + total_steps: Total number of training steps + """ + self._enabled = enabled + self._total_steps = total_steps + self._train_task: TaskID | None = None + + if not enabled: + self._progress = None + return + + # Single Progress instance with flexible columns + self._progress = Progress( + TextColumn("[progress.description]{task.description}"), + TextColumn("{task.fields[video]}", style="magenta"), + BarColumn(bar_width=40, style="blue"), + TextColumn("{task.fields[info]}", style="cyan"), + TimeElapsedColumn(), + TextColumn("ETA:"), + TimeRemainingColumn(compact=True), + ) + + def __enter__(self) -> "TrainingProgress": + """Enter the progress context, starting the live display.""" + if self._progress is not None: + self._progress.__enter__() + self._train_task = self._progress.add_task( + "Training", + total=self._total_steps, + video=f"0/{self._total_steps}", + info="Starting...", + ) + return self + + def __exit__(self, *args) -> None: + """Exit the progress context, stopping the live display.""" + if self._progress is not None: + self._progress.__exit__(*args) + + @property + def enabled(self) -> bool: + """Whether progress display is enabled.""" + return self._enabled + + def update_training( + self, + *, + loss: float, + lr: float, + step_time: float, + advance: bool = True, + ) -> None: + """Update the training progress display. + Args: + loss: Current training loss + lr: Current learning rate + step_time: Time taken for this step in seconds + advance: Whether to advance the progress by one step + """ + if self._progress is None or self._train_task is None: + return + + info = f"Loss: {loss:.4f} | LR: {lr:.2e} | {step_time:.2f}s/step" + self._progress.update( + self._train_task, + advance=1 if advance else 0, + info=info, + ) + # Update step count in video column + completed = int(self._progress.tasks[self._train_task].completed) + self._progress.update(self._train_task, video=f"{completed}/{self._total_steps}") + + def start_sampling(self, num_prompts: int, num_steps: int) -> SamplingContext: + """Start validation sampling progress tracking. + Creates a task that shows current video and denoising step progress. + Format: "Sampling X/Y [████████████] step Z/W" + Args: + num_prompts: Number of validation prompts to sample + num_steps: Number of denoising steps per sample + Returns: + SamplingContext for tracking progress (no-op if progress is disabled) + """ + if self._progress is None: + # Return a no-op context when progress is disabled + return SamplingContext( + progress=None, + task=None, + num_prompts=num_prompts, + num_steps=num_steps, + ) + + task = self._progress.add_task( + "Sampling", + total=num_steps, + completed=0, + video=f"0/{num_prompts}", + info=f"step 0/{num_steps}", + ) + + return SamplingContext( + progress=self._progress, + task=task, + num_prompts=num_prompts, + num_steps=num_steps, + ) diff --git a/packages/ltx-trainer/src/ltx_trainer/quantization.py b/packages/ltx-trainer/src/ltx_trainer/quantization.py new file mode 100644 index 0000000000000000000000000000000000000000..31bc9bc62f16c5c7a42729a8eba98a8de1461402 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/quantization.py @@ -0,0 +1,195 @@ +# Adapted from: https://github.com/bghira/SimpleTuner +# With improvements from: https://github.com/ostris/ai-toolkit +from typing import Literal + +import torch +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn + +from ltx_trainer import logger + +QuantizationOptions = Literal[ + "int8-quanto", + "int4-quanto", + "int2-quanto", + "fp8-quanto", + "fp8uz-quanto", +] + +# Modules to exclude from quantization. +# These are glob patterns passed to quanto's `exclude` parameter. +# When quantizing the full model at once, these patterns match against full module paths. +# When quantizing block-by-block, we also use SKIP_ROOT_MODULES for top-level modules. +EXCLUDE_PATTERNS = [ + # Input/output projection layers + "patchify_proj", + "audio_patchify_proj", + "proj_out", + "audio_proj_out", + # Timestep embedding layers - int4 tinygemm requires strict bfloat16 input + # and these receive float32 sinusoidal embeddings that are cast to bfloat16 + "*adaln*", + "time_proj", + "timestep_embedder*", + # Caption/text projection layers + "caption_projection*", + "audio_caption_projection*", + # Normalization layers (usually excluded from quantization) + "*norm*", +] + +# Top-level modules to skip entirely during block-by-block quantization. +# These are exact matches against model.named_children() names. +# (Needed because quanto's exclude patterns don't work when calling quantize() directly on a module) +SKIP_ROOT_MODULES = { + "patchify_proj", + "audio_patchify_proj", + "proj_out", + "audio_proj_out", + "audio_caption_projection", +} + + +def quantize_model( + model: torch.nn.Module, + precision: QuantizationOptions, + quantize_activations: bool = False, + device: torch.device | str | None = None, +) -> torch.nn.Module: + """ + Quantize a model using optimum-quanto. + For large models with transformer_blocks, this function quantizes block-by-block + on GPU then moves back to CPU, which is much faster than quantizing on CPU and + uses less peak VRAM than loading the entire model to GPU at once. + Args: + model: The model to quantize. + precision: The quantization precision (e.g. "int8-quanto", "fp8-quanto"). + quantize_activations: Whether to quantize activations in addition to weights. + device: Device to use for quantization. If None, uses CUDA if available, else CPU. + Returns: + The quantized model. + """ + from optimum.quanto import freeze, quantize # noqa: PLC0415 + + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + elif isinstance(device, str): + device = torch.device(device) + + weight_quant = _get_quanto_dtype(precision) + + if quantize_activations: + logger.debug("Quantizing model weights and activations") + activations_quant = weight_quant + else: + activations_quant = None + + # Remember original device to restore after quantization + original_device = next(model.parameters()).device + + # Check if model has transformer_blocks for block-by-block quantization + if hasattr(model, "transformer_blocks"): + logger.debug("Quantizing model using block-by-block approach for memory efficiency") + _quantize_blockwise( + model, + weight_quant=weight_quant, + activations_quant=activations_quant, + device=device, + ) + else: + # Fallback: quantize entire model at once + model.to(device) + quantize(model, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS) + freeze(model) + + # Restore model to original device + model.to(original_device) + + return model + + +def _quantize_blockwise( + model: torch.nn.Module, + weight_quant: torch.dtype, + activations_quant: torch.dtype | None, + device: torch.device, +) -> None: + """Quantize a model block-by-block using optimum-quanto. + This approach: + 1. Moves each transformer block to GPU + 2. Quantizes on GPU (fast!) + 3. Freezes the quantized weights + 4. Moves back to CPU + This is much faster than quantizing on CPU and uses less peak VRAM + than loading the entire model to GPU. + """ + from optimum.quanto import freeze, quantize # noqa: PLC0415 + + original_dtype = next(model.parameters()).dtype + transformer_blocks = list(model.transformer_blocks) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + transient=True, + ) as progress: + task = progress.add_task("Quantizing transformer blocks", total=len(transformer_blocks)) + + for block in transformer_blocks: + # Move block to GPU + block.to(device, dtype=original_dtype, non_blocking=True) + + # Quantize on GPU + quantize(block, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS) + freeze(block) + + # Move back to CPU to free up VRAM for next block + block.to("cpu", non_blocking=True) + + progress.advance(task) + + # Quantize remaining non-transformer-block modules (e.g., embeddings, timestep projections) + # Skip modules that should not be quantized (patchify_proj, proj_out, etc.) + logger.debug("Quantizing remaining model components") + + for name, module in model.named_children(): + if name == "transformer_blocks": + continue # Already quantized + + if name in SKIP_ROOT_MODULES: + logger.debug(f"Skipping quantization for module: {name}") + continue # Don't quantize these modules + + # Move to device, quantize, freeze, move back + module.to(device, dtype=original_dtype, non_blocking=True) + quantize(module, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS) + freeze(module) + module.to("cpu", non_blocking=True) + + +def _get_quanto_dtype(precision: QuantizationOptions) -> torch.dtype: + """Map precision string to quanto dtype.""" + from optimum.quanto import ( # noqa: PLC0415 + qfloat8, + qfloat8_e4m3fnuz, + qint2, + qint4, + qint8, + ) + + if precision == "int2-quanto": + return qint2 + elif precision == "int4-quanto": + return qint4 + elif precision == "int8-quanto": + return qint8 + elif precision in ("fp8-quanto", "fp8uz-quanto"): + if torch.backends.mps.is_available(): + raise ValueError("FP8 quantization is not supported on MPS devices. Use int2, int4, or int8 instead.") + if precision == "fp8-quanto": + return qfloat8 + elif precision == "fp8uz-quanto": + return qfloat8_e4m3fnuz + + raise ValueError(f"Invalid quantization precision: {precision}") diff --git a/packages/ltx-trainer/src/ltx_trainer/sigma_tracker.py b/packages/ltx-trainer/src/ltx_trainer/sigma_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..499c0c044d520b5313c1cac7de53a7662dd69391 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/sigma_tracker.py @@ -0,0 +1,59 @@ +"""Sigma-bucketed loss tracking. +Maps each training step's per-element sigmas and losses to buckets. +Smoothing is left to wandb's UI. +""" + +import bisect +from collections import defaultdict + + +class SigmaBucketTracker: + """Map per-element sigma values to named buckets for per-bucket loss logging. + By default, partitions [0, 1] into four equal-width buckets. + Custom boundaries can be provided for non-uniform bucketing. + Each call to update() receives per-element sigmas and losses (both [B,]), + buckets each element, and computes the mean loss per bucket. This gives + accurate per-sigma loss tracking even for batch_size > 1. + """ + + def __init__( + self, + bucket_boundaries: list[float] | None = None, + ) -> None: + if bucket_boundaries is None: + bucket_boundaries = [0.0, 0.25, 0.5, 0.75, 1.0] + if len(bucket_boundaries) < 2: + raise ValueError("bucket_boundaries must have at least 2 elements") + if any(bucket_boundaries[i] >= bucket_boundaries[i + 1] for i in range(len(bucket_boundaries) - 1)): + raise ValueError("bucket_boundaries must be strictly increasing") + self._boundaries = list(bucket_boundaries) + self._num_buckets = len(bucket_boundaries) - 1 + self._bucket_labels = [ + f"{bucket_boundaries[i]:.2f}-{bucket_boundaries[i + 1]:.2f}" for i in range(self._num_buckets) + ] + self._last_metrics: dict[str, float] = {} + + def _get_bucket_index(self, sigma: float) -> int: + """Map sigma value to bucket index.""" + idx = bisect.bisect_right(self._boundaries, sigma) - 1 + return max(0, min(idx, self._num_buckets - 1)) + + def update(self, sigmas: list[float], losses: list[float]) -> None: + """Record per-element losses into their sigma buckets. + Args: + sigmas: Per-element sigma values, one per batch element. + losses: Per-element losses, one per batch element. + """ + if not sigmas: + self._last_metrics = {} + return + bucket_losses: dict[int, list[float]] = defaultdict(list) + for sigma, loss in zip(sigmas, losses, strict=True): + bucket_losses[self._get_bucket_index(sigma)].append(loss) + self._last_metrics = {self._bucket_labels[b]: sum(vals) / len(vals) for b, vals in bucket_losses.items()} + + def get_metrics(self, prefix: str = "train") -> dict[str, float]: + """Return the mean loss for each bucket hit on the last update. + Wandb handles smoothing in the UI. + """ + return {f"{prefix}/loss_sigma_{label}": loss for label, loss in self._last_metrics.items()} diff --git a/packages/ltx-trainer/src/ltx_trainer/timestep_samplers.py b/packages/ltx-trainer/src/ltx_trainer/timestep_samplers.py new file mode 100644 index 0000000000000000000000000000000000000000..30e849479bcc00ffeb5fa00ef2e77759112cc8e2 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/timestep_samplers.py @@ -0,0 +1,160 @@ +import torch + + +class TimestepSampler: + """Base class for timestep samplers. + Timestep samplers are used to sample timesteps for diffusion models. + They should implement both sample() and sample_for() methods. + """ + + def sample(self, batch_size: int, seq_length: int | None = None, device: torch.device = None) -> torch.Tensor: + """Sample timesteps for a batch. + Args: + batch_size: Number of timesteps to sample + seq_length: (optional) Length of the sequence being processed + device: Device to place the samples on + Returns: + Tensor of shape (batch_size,) containing timesteps + """ + raise NotImplementedError + + def sample_for(self, batch: torch.Tensor) -> torch.Tensor: + """Sample timesteps for a specific batch tensor. + Args: + batch: Input tensor of shape (batch_size, seq_length, ...) + Returns: + Tensor of shape (batch_size,) containing timesteps + """ + raise NotImplementedError + + +class UniformTimestepSampler(TimestepSampler): + """Samples timesteps uniformly between min_value and max_value (default 0 and 1).""" + + def __init__(self, min_value: float = 0.0, max_value: float = 1.0): + self.min_value = min_value + self.max_value = max_value + + def sample(self, batch_size: int, seq_length: int | None = None, device: torch.device = None) -> torch.Tensor: # noqa: ARG002 + return torch.rand(batch_size, device=device) * (self.max_value - self.min_value) + self.min_value + + def sample_for(self, batch: torch.Tensor) -> torch.Tensor: + if batch.ndim != 3: + raise ValueError(f"Batch should have 3 dimensions, got {batch.ndim}") + + return self.sample(batch.shape[0], device=batch.device) + + +class ShiftedLogitNormalTimestepSampler(TimestepSampler): + """ + Samples timesteps from a stretched shifted logit-normal distribution, + where the shift is determined by the sequence length. + The stretching normalizes samples between percentile bounds to ensure + the distribution covers [0, 1] more evenly. A uniform fallback prevents + collapse at high token counts. + """ + + def __init__(self, std: float = 1.0, eps: float = 1e-3, uniform_prob: float = 0.1): + self.std = std + self.eps = eps + self.uniform_prob = uniform_prob + # Percentile values for stretching (scaled by std) + # 99.9th percentile of standard normal ≈ 3.0902 + # 0.5th percentile of standard normal ≈ -2.5758 + self.normal_999_percentile = 3.0902 * std + self.normal_005_percentile = -2.5758 * std + + def sample(self, batch_size: int, seq_length: int, device: torch.device = None) -> torch.Tensor: + """Sample timesteps for a batch from a stretched shifted logit-normal distribution. + Args: + batch_size: Number of timesteps to sample + seq_length: Length of the sequence being processed, used to determine the shift + device: Device to place the samples on + Returns: + Tensor of shape (batch_size,) containing timesteps sampled from a stretched + shifted logit-normal distribution, where the shift is determined by seq_length + """ + mu = self._get_shift_for_sequence_length(seq_length) + + # Sample from shifted logit-normal + normal_samples = torch.randn((batch_size,), device=device) * self.std + mu + logitnormal_samples = torch.sigmoid(normal_samples) + + # Compute percentile bounds for stretching + percentile_999 = torch.sigmoid(torch.tensor(mu + self.normal_999_percentile, device=device)) + percentile_005 = torch.sigmoid(torch.tensor(mu + self.normal_005_percentile, device=device)) + + # Stretch to [0, 1] range by normalizing between percentiles + zero_terminal_raw = (logitnormal_samples - percentile_005) / (percentile_999 - percentile_005) + + # Reflect small values around eps for numerical stability + stretched_logit = torch.where( + zero_terminal_raw >= self.eps, + zero_terminal_raw, + 2 * self.eps - zero_terminal_raw, + ) + stretched_logit = torch.clamp(stretched_logit, 0, 1) + + # Mix with uniform samples (uniform_prob of the time) + uniform = (1 - self.eps) * torch.rand((batch_size,), device=device) + self.eps + prob = torch.rand((batch_size,), device=device) + + return torch.where(prob > self.uniform_prob, stretched_logit, uniform) + + def sample_for(self, batch: torch.Tensor) -> torch.Tensor: + """Sample timesteps for a specific batch tensor. + Args: + batch: Input tensor of shape (batch_size, seq_length, ...) + Returns: + Tensor of shape (batch_size,) containing timesteps sampled from a shifted + logit-normal distribution, where the shift is determined by the sequence length + of the input batch + Raises: + ValueError: If the input batch does not have 3 dimensions + """ + if batch.ndim != 3: + raise ValueError(f"Batch should have 3 dimensions, got {batch.ndim}") + + batch_size, seq_length, _ = batch.shape + return self.sample(batch_size, seq_length, device=batch.device) + + @staticmethod + def _get_shift_for_sequence_length( + seq_length: int, + min_tokens: int = 1024, + max_tokens: int = 4096, + min_shift: float = 0.95, + max_shift: float = 2.05, + ) -> float: + # Calculate the shift value for a given sequence length using linear interpolation + # between min_shift and max_shift based on sequence length. + m = (max_shift - min_shift) / (max_tokens - min_tokens) # Calculate slope + b = min_shift - m * min_tokens # Calculate y-intercept + shift = m * seq_length + b # Apply linear equation y = mx + b + return shift + + +SAMPLERS = { + "uniform": UniformTimestepSampler, + "shifted_logit_normal": ShiftedLogitNormalTimestepSampler, +} + + +def example() -> None: + # noinspection PyUnresolvedReferences + import matplotlib.pyplot as plt # noqa: PLC0415 + + sampler = ShiftedLogitNormalTimestepSampler() + for seq_length in [1024, 2048, 4096, 8192]: + samples = sampler.sample(batch_size=1_000_000, seq_length=seq_length) + + # plot the histogram of the samples + plt.hist(samples.numpy(), bins=100, density=True) + plt.title(f"Timestep Samples for Sequence Length {seq_length}") + plt.xlabel("Timestep") + plt.ylabel("Density") + plt.show() + + +if __name__ == "__main__": + example() diff --git a/packages/ltx-trainer/src/ltx_trainer/trainer.py b/packages/ltx-trainer/src/ltx_trainer/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..8eb3fbc5d5931a94360d2c81829a8dfcbe0c8bf2 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/trainer.py @@ -0,0 +1,1117 @@ +import contextlib +import math +import os +import re +import time +import warnings +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import torch +import wandb +import yaml +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 +from pydantic import BaseModel +from safetensors.torch import load_file, save_file +from torch import Tensor +from torch.optim import AdamW +from torch.optim.lr_scheduler import ( + CosineAnnealingLR, + CosineAnnealingWarmRestarts, + LinearLR, + LRScheduler, + PolynomialLR, + StepLR, +) +from torch.utils.data import DataLoader + +from 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, get_gpu_memory_gb +from ltx_trainer.hf_hub_utils import push_to_hub +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.validation_runner import ValidationRunner + +# Disable irrelevant warnings from transformers +os.environ["TOKENIZERS_PARALLELISM"] = "true" + +# Silence bitsandbytes warnings about casting +warnings.filterwarnings( + "ignore", message="MatMul8bitLt: inputs will be cast from torch.bfloat16 to float16 during quantization" +) + +# Disable progress bars if not main process +IS_MAIN_PROCESS = os.environ.get("LOCAL_RANK", "0") == "0" +if not IS_MAIN_PROCESS: + from transformers.utils.logging import disable_progress_bar + + disable_progress_bar() + +StepCallback = Callable[[int, int, list[Path]], None] # (step, total, list[sampled_video_path]) -> None + +MEMORY_CHECK_INTERVAL = 200 + + +class TrainingStats(BaseModel): + """Statistics collected during training""" + + total_time_seconds: float + steps_per_second: float + samples_per_second: float + peak_gpu_memory_gb: float + global_batch_size: int + num_processes: int + + +@dataclass(frozen=True) +class TrainingStepOutput: + """Output from a single training step.""" + + loss: Tensor # [B,] per-element loss (unreduced) + sigma: Tensor # [B,] sampled sigma, detached from computational graph + + +class LtxvTrainer: + def __init__(self, trainer_config: LtxTrainerConfig) -> None: + self._config = trainer_config + if IS_MAIN_PROCESS: + print_config(trainer_config) + self._training_strategy = get_training_strategy(self._config.training_strategy) + + # 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() + self._loaded_checkpoint_path: Path | None = None + self._load_checkpoint() + self._prepare_models_for_training() + self._dataset = None + self._global_step = -1 + self._checkpoint_paths: list[Path] = [] + self._training_state_paths: list[Path] = [] + self._training_state_size_warned = False + self._sigma_tracker = SigmaBucketTracker() + self._wandb_run = None + + def train( # noqa: PLR0912, PLR0915 + self, + disable_progress_bars: bool = False, + step_callback: StepCallback | None = None, + ) -> tuple[Path, TrainingStats]: + """ + Start the training process. + Args: + disable_progress_bars: Disable Rich progress bars (useful for multi-process runs). + step_callback: Optional callback invoked after each optimization step. + Returns: + Tuple of (saved_model_path, training_stats) + """ + device = self._accelerator.device + cfg = self._config + start_mem = get_gpu_memory_gb(device) + + train_start_time = time.time() + + initial_step, training_state = self._resume_state + resuming = training_state is not None + + set_seed(cfg.seed) + logger.debug(f"Process {self._accelerator.process_index} using seed: {cfg.seed}") + + self._init_optimizer() + + if training_state is not None and not self._restore_training_state(training_state): + initial_step = 0 + resuming = False + + # Initialize W&B after restore so we only resume the run when state restore succeeds. + resume_run_id = training_state.wandb_run_id if resuming and training_state is not None else None + self._init_wandb(resume_run_id=resume_run_id) + + self._init_dataloader() + data_iter = iter(self._dataloader) + self._init_timestep_sampler() + + # Synchronize all processes after initialization + self._accelerator.wait_for_everyone() + + Path(cfg.output_dir).mkdir(parents=True, exist_ok=True) + + # Save the training configuration as YAML + self._save_config() + + remaining_steps = cfg.optimization.steps - initial_step + if remaining_steps <= 0: + raise ValueError( + f"No remaining training steps: initial_step={initial_step} >= " + f"target_steps={cfg.optimization.steps}. Nothing to train." + ) + + if resuming: + logger.info(f"🚀 Resuming training from step {initial_step} → {cfg.optimization.steps}") + else: + logger.info("🚀 Starting training...") + + # Create progress tracking (disabled for non-main processes or when explicitly disabled) + progress_enabled = IS_MAIN_PROCESS and not disable_progress_bars + progress = TrainingProgress( + enabled=progress_enabled, + total_steps=remaining_steps, + ) + + if IS_MAIN_PROCESS and disable_progress_bars: + logger.warning("Progress bars disabled. Intermediate status messages will be logged instead.") + + self._transformer.train() + self._global_step = initial_step + + peak_mem_during_training = start_mem + + sampled_videos_paths = None + + with progress: + if cfg.validation.interval and not cfg.validation.skip_initial_validation: + with self._offloaded_optimizer_state(): + sampled_videos_paths = self._run_validation(progress) + + self._accelerator.wait_for_everyone() + + for step in range(remaining_steps * cfg.optimization.gradient_accumulation_steps): + # Get next batch, reset the dataloader if needed + try: + batch = next(data_iter) + except StopIteration: + data_iter = iter(self._dataloader) + batch = next(data_iter) + + step_start_time = time.time() + with self._accelerator.accumulate(self._transformer): + is_optimization_step = (step + 1) % cfg.optimization.gradient_accumulation_steps == 0 + if is_optimization_step: + self._global_step += 1 + + output = self._training_step(batch) + self._accelerator.backward(output.loss.mean()) + + if self._accelerator.sync_gradients and cfg.optimization.max_grad_norm > 0: + self._accelerator.clip_grad_norm_( + self._trainable_params, + cfg.optimization.max_grad_norm, + ) + + self._optimizer.step() + self._optimizer.zero_grad() + + if self._lr_scheduler is not None: + self._lr_scheduler.step() + + # Run validation if needed (handles DDP/FSDP work distribution internally) + if ( + cfg.validation.interval + and self._global_step > 0 + and self._global_step % cfg.validation.interval == 0 + and is_optimization_step + ): + with self._offloaded_optimizer_state(): + sampled_videos_paths = self._run_validation(progress) + + # Save checkpoint if needed + if ( + cfg.checkpoints.interval + and self._global_step > 0 + and self._global_step % cfg.checkpoints.interval == 0 + and is_optimization_step + ): + self._save_checkpoint() + + self._accelerator.wait_for_everyone() + + # Call step callback if provided + if step_callback and is_optimization_step: + step_callback(self._global_step, cfg.optimization.steps, sampled_videos_paths) + + self._accelerator.wait_for_everyone() + + # Update progress and log metrics + current_lr = self._optimizer.param_groups[0]["lr"] + step_time = (time.time() - step_start_time) * cfg.optimization.gradient_accumulation_steps + step_loss = output.loss.detach().mean().item() + + progress.update_training( + loss=step_loss, + lr=current_lr, + step_time=step_time, + advance=is_optimization_step, + ) + + # Log metrics to W&B (only on main process and optimization steps) + if IS_MAIN_PROCESS and is_optimization_step: + # Track per-element loss by sigma bucket + self._sigma_tracker.update(output.sigma.cpu().tolist(), output.loss.detach().cpu().tolist()) + metrics = { + "train/loss": step_loss, + "train/learning_rate": current_lr, + "train/step_time": step_time, + "train/global_step": self._global_step, + } + metrics.update(self._sigma_tracker.get_metrics()) + self._log_metrics(metrics) + + # Fallback logging when progress bars are disabled + if disable_progress_bars and IS_MAIN_PROCESS and self._global_step % 20 == 0: + elapsed = time.time() - train_start_time + steps_done = self._global_step - initial_step + if steps_done > 0: + total_estimated = elapsed / steps_done * remaining_steps + total_time = f"{total_estimated // 3600:.0f}h {(total_estimated % 3600) // 60:.0f}m" + else: + total_time = "calculating..." + logger.info( + f"Step {self._global_step}/{cfg.optimization.steps} - " + f"Loss: {step_loss:.4f}, LR: {current_lr:.2e}, " + f"Time/Step: {step_time:.2f}s, Total Time: {total_time}", + ) + + # Sample GPU memory periodically + if step % MEMORY_CHECK_INTERVAL == 0: + current_mem = get_gpu_memory_gb(device) + peak_mem_during_training = max(peak_mem_during_training, current_mem) + + # Collect final stats + train_end_time = time.time() + end_mem = get_gpu_memory_gb(device) + peak_mem = max(start_mem, end_mem, peak_mem_during_training) + + # Calculate steps/second over entire training + total_time_seconds = train_end_time - train_start_time + steps_per_second = remaining_steps / total_time_seconds + + samples_per_second = steps_per_second * self._accelerator.num_processes * cfg.optimization.batch_size + + stats = TrainingStats( + total_time_seconds=total_time_seconds, + steps_per_second=steps_per_second, + samples_per_second=samples_per_second, + peak_gpu_memory_gb=peak_mem, + num_processes=self._accelerator.num_processes, + global_batch_size=cfg.optimization.batch_size * self._accelerator.num_processes, + ) + + saved_path = self._save_checkpoint() + + if IS_MAIN_PROCESS: + # Log the training statistics + self._log_training_stats(stats) + + # Upload artifacts to hub if enabled + if cfg.hub.push_to_hub: + push_to_hub(saved_path, sampled_videos_paths, self._config) + + # Log final stats to W&B + if self._wandb_run is not None: + self._log_metrics( + { + "stats/total_time_minutes": stats.total_time_seconds / 60, + "stats/steps_per_second": stats.steps_per_second, + "stats/samples_per_second": stats.samples_per_second, + "stats/peak_gpu_memory_gb": stats.peak_gpu_memory_gb, + } + ) + self._wandb_run.finish() + + self._accelerator.wait_for_everyone() + self._accelerator.end_training() + + return saved_path, stats + + def _training_step(self, batch: dict[str, dict[str, Tensor]]) -> TrainingStepOutput: + """Perform a single training step using the configured strategy.""" + # Apply embedding connectors to transform pre-computed text embeddings + conditions = batch["conditions"] + + if "video_prompt_embeds" in conditions: + # New format: separate video/audio features from precompute() + video_features = conditions["video_prompt_embeds"] + audio_features = conditions.get("audio_prompt_embeds") + else: + # Legacy format: single prompt_embeds tensor — duplicate for both modalities + video_features = conditions["prompt_embeds"] + audio_features = conditions["prompt_embeds"] + + mask = conditions["prompt_attention_mask"] + additive_mask = convert_to_additive_mask(mask, video_features.dtype) + video_embeds, audio_embeds, attention_mask = self._embeddings_processor.create_embeddings( + video_features, audio_features, additive_mask + ) + + conditions["video_prompt_embeds"] = video_embeds + conditions["audio_prompt_embeds"] = audio_embeds + conditions["prompt_attention_mask"] = attention_mask + + # Use strategy to prepare training inputs (returns ModelInputs with Modality objects) + model_inputs = self._training_strategy.prepare_training_inputs(batch, self._timestep_sampler) + + # Run transformer forward pass with Modality-based interface + video_pred, audio_pred = self._transformer( + video=model_inputs.video, + audio=model_inputs.audio, + perturbations=None, + ) + + # Use strategy to compute loss (returns per-element [B,] for sigma-bucket tracking) + loss = self._training_strategy.compute_loss(video_pred, audio_pred, model_inputs) + + # 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) + + 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, + ) + + # 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 embeddings processor...") + self._embeddings_processor = load_embeddings_processor( + checkpoint_path=self._config.model.model_path, + device=init_device, + dtype=torch.bfloat16, + ) + self._embeddings_processor.feature_extractor = None + + transformer_dtype = torch.bfloat16 if self._config.model.training_mode == "lora" else torch.float32 + self._transformer = self._transformer.to(dtype=transformer_dtype) + + if self._config.acceleration.quantization is not None: + if self._config.model.training_mode == "full": + raise ValueError("Quantization is not supported in full training mode.") + + logger.info(f'Quantizing model with "{self._config.acceleration.quantization}". This may take a while...') + self._transformer = quantize_model( + self._transformer, + precision=self._config.acceleration.quantization, + ) + + self._transformer.requires_grad_(False) + + def _collect_trainable_params(self) -> None: + """Collect trainable parameters based on training mode.""" + if self._config.model.training_mode == "lora": + # For LoRA training, first set up LoRA layers + self._setup_lora() + elif self._config.model.training_mode == "full": + # For full training, unfreeze all transformer parameters + self._transformer.requires_grad_(True) + else: + raise ValueError(f"Unknown training mode: {self._config.model.training_mode}") + + self._trainable_params = [p for p in self._transformer.parameters() if p.requires_grad] + logger.debug(f"Trainable params count: {sum(p.numel() for p in self._trainable_params):,}") + + def _init_timestep_sampler(self) -> None: + """Initialize the timestep sampler based on the config.""" + sampler_cls = SAMPLERS[self._config.flow_matching.timestep_sampling_mode] + self._timestep_sampler = sampler_cls(**self._config.flow_matching.timestep_sampling_params) + + def _setup_lora(self) -> None: + """Configure LoRA adapters for the transformer. Only called in LoRA training mode.""" + logger.debug(f"Adding LoRA adapter with rank {self._config.lora.rank}") + lora_config = LoraConfig( + r=self._config.lora.rank, + lora_alpha=self._config.lora.alpha, + target_modules=self._config.lora.target_modules, + lora_dropout=self._config.lora.dropout, + init_lora_weights=True, + ) + # Wrap the transformer with PEFT to add LoRA layers + # noinspection PyTypeChecker + self._transformer = get_peft_model(self._transformer, lora_config) + + def _load_checkpoint(self) -> None: + """Load checkpoint if specified in config, then resolve resume state.""" + if not self._config.model.load_checkpoint: + self._resume_state: tuple[int, TrainingState | None] = (0, None) + return + + checkpoint_path = self._find_checkpoint(self._config.model.load_checkpoint) + if not checkpoint_path: + logger.warning(f"⚠️ Could not find checkpoint at {self._config.model.load_checkpoint}") + self._resume_state = (0, None) + return + + self._loaded_checkpoint_path = checkpoint_path + logger.info(f"📥 Loading checkpoint from {checkpoint_path}") + + if self._config.model.training_mode == "full": + self._load_full_checkpoint(checkpoint_path) + else: # LoRA mode + self._load_lora_checkpoint(checkpoint_path) + + self._resume_state = self._resolve_resume_state() + + def _load_full_checkpoint(self, checkpoint_path: Path) -> None: + """Load full model checkpoint.""" + state_dict = load_file(checkpoint_path) + self._transformer.load_state_dict(state_dict, strict=True) + + logger.info("✅ Full model checkpoint loaded successfully") + + def _load_lora_checkpoint(self, checkpoint_path: Path) -> None: + """Load LoRA checkpoint with DDP/FSDP compatibility.""" + state_dict = load_file(checkpoint_path) + + # Adjust layer names to match internal format. + # (Weights are saved in ComfyUI-compatible format, with "diffusion_model." prefix) + state_dict = {k.replace("diffusion_model.", "", 1): v for k, v in state_dict.items()} + + # Load LoRA weights and verify all weights were loaded + base_model = self._transformer.get_base_model() + set_peft_model_state_dict(base_model, state_dict) + + logger.info("✅ LoRA checkpoint loaded successfully") + + def _resolve_resume_state(self) -> tuple[int, TrainingState | None]: + """Determine resume state by looking for a training state file next to the loaded checkpoint. + Returns (initial_step, TrainingState or None). + If no_resume config is set, no checkpoint loaded, or no state file found: returns (0, None). + """ + if self._config.checkpoints.no_resume or self._loaded_checkpoint_path is None: + return 0, None + + state = self._load_training_state(self._loaded_checkpoint_path) + if state is None: + return 0, None + + fp = state.config_fingerprint + cfg = self._config + mismatches: list[str] = [] + if fp.optimizer_type != cfg.optimization.optimizer_type: + mismatches.append(f"optimizer_type: {fp.optimizer_type} → {cfg.optimization.optimizer_type}") + if fp.scheduler_type != cfg.optimization.scheduler_type: + mismatches.append(f"scheduler_type: {fp.scheduler_type} → {cfg.optimization.scheduler_type}") + if fp.training_mode != cfg.model.training_mode: + mismatches.append(f"training_mode: {fp.training_mode} → {cfg.model.training_mode}") + if ( + cfg.model.training_mode == "lora" + and cfg.lora is not None + and fp.lora_rank is not None + and fp.lora_rank != cfg.lora.rank + ): + mismatches.append(f"lora_rank: {fp.lora_rank} → {cfg.lora.rank}") + if mismatches: + logger.warning( + f"⚠️ Training state config mismatch ({', '.join(mismatches)}). " + "Starting from step 0. Set checkpoints.no_resume=true to silence this warning." + ) + return 0, None + + if state.global_step < 0: + logger.warning(f"⚠️ Training state has invalid global_step={state.global_step!r}. Starting from step 0.") + return 0, None + logger.info(f"📌 Resuming from step {state.global_step}") + return state.global_step, state + + @staticmethod + def _load_training_state(checkpoint_path: Path) -> TrainingState | None: + """Load training state file that corresponds to a checkpoint weights file.""" + match = re.search(r"step_(\d+)", checkpoint_path.name) + if not match: + return None + + step_str = match.group(1) + state_path = checkpoint_path.parent / f"training_state_step_{step_str}.pt" + + if not state_path.exists(): + return None + + try: + raw: dict = torch.load(state_path, map_location="cpu", weights_only=False) + state = TrainingState.from_save_dict(raw) + logger.info(f"📥 Loaded training state from {state_path}") + return state + except Exception as e: + logger.warning(f"⚠️ Failed to load training state from {state_path}: {e}. Starting from step 0.") + return None + + def _restore_training_state(self, training_state: TrainingState) -> bool: + """Restore optimizer, scheduler, and RNG states from a loaded TrainingState. + Must be called after _init_optimizer() (which calls accelerator.prepare). + Returns True if restore succeeded, False if it failed (caller should fall back to step 0). + """ + try: + if training_state.optimizer_state_dict is not None: + self._optimizer.load_state_dict(training_state.optimizer_state_dict) + logger.debug("Restored optimizer state (full mode)") + + if training_state.lr_scheduler_state_dict is not None and self._lr_scheduler is not None: + self._lr_scheduler.load_state_dict(training_state.lr_scheduler_state_dict) + logger.debug("Restored LR scheduler state") + except Exception as e: + logger.warning(f"⚠️ Failed to restore training state: {e}. Starting from step 0.") + return False + + rng = training_state.rng_states + if self._accelerator.num_processes > 1: + logger.debug("Skipping RNG restore in multi-process mode (only main process state was saved)") + else: + if rng.torch_state is not None: + torch.random.set_rng_state(rng.torch_state) + if rng.cuda_state is not None and torch.cuda.is_available(): + torch.cuda.set_rng_state(rng.cuda_state) + logger.debug("Restored RNG states") + + return True + + def _prepare_models_for_training(self) -> None: + """Prepare models for training with Accelerate.""" + + # For FSDP + LoRA: Cast entire model to FP32. + # FSDP requires uniform dtype across all parameters in wrapped modules. + # In LoRA mode, PEFT creates LoRA params in FP32 while base model is BF16. + # We cast the base model to FP32 to match the LoRA params. + if self._accelerator.distributed_type == DistributedType.FSDP and self._config.model.training_mode == "lora": + logger.debug("FSDP: casting transformer to FP32 for uniform dtype") + self._transformer = self._transformer.to(dtype=torch.float32) + + # Enable gradient checkpointing if requested + # For PeftModel, we need to access the underlying base model + transformer = ( + self._transformer.get_base_model() if hasattr(self._transformer, "get_base_model") else self._transformer + ) + + transformer.set_gradient_checkpointing(self._config.optimization.enable_gradient_checkpointing) + + # noinspection PyTypeChecker + self._transformer = self._accelerator.prepare(self._transformer) + + # Log GPU memory usage after model preparation + vram_usage_gb = torch.cuda.memory_allocated() / 1024**3 + logger.debug(f"GPU memory usage after models preparation: {vram_usage_gb:.2f} GB") + + @staticmethod + def _find_checkpoint(checkpoint_path: str | Path) -> Path | None: + """Find the checkpoint file to load, handling both file and directory paths.""" + checkpoint_path = Path(checkpoint_path) + + if checkpoint_path.is_file(): + if not checkpoint_path.suffix == ".safetensors": + raise ValueError(f"Checkpoint file must have a .safetensors extension: {checkpoint_path}") + return checkpoint_path + + if checkpoint_path.is_dir(): + # Look for checkpoint files in the directory + checkpoints = list(checkpoint_path.rglob("*step_*.safetensors")) + + if not checkpoints: + return None + + # Sort by step number and return the latest + def _get_step_num(p: Path) -> int: + try: + return int(p.stem.split("step_")[1]) + except (IndexError, ValueError): + return -1 + + latest = max(checkpoints, key=_get_step_num) + return latest + + else: + raise ValueError(f"Invalid checkpoint path: {checkpoint_path}. Must be a file or directory.") + + def _init_dataloader(self) -> None: + """Initialize the training data loader using the strategy's data sources.""" + if self._dataset is None: + # Get data sources from the training strategy + data_sources = self._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)}") + + num_workers = self._config.data.num_dataloader_workers + dataloader = DataLoader( + self._dataset, + batch_size=self._config.optimization.batch_size, + shuffle=True, + drop_last=True, + num_workers=num_workers, + pin_memory=num_workers > 0, + persistent_workers=num_workers > 0, + ) + + self._dataloader = self._accelerator.prepare(dataloader) + + def _init_lora_weights(self) -> None: + """Initialize LoRA weights for the transformer.""" + logger.debug("Initializing LoRA weights...") + for _, module in self._transformer.named_modules(): + if isinstance(module, (BaseTunerLayer, ModulesToSaveWrapper)): + module.reset_lora_parameters(adapter_name="default", init_lora_weights=True) + + def _init_optimizer(self) -> None: + """Initialize the optimizer and learning rate scheduler.""" + opt_cfg = self._config.optimization + + lr = opt_cfg.learning_rate + if opt_cfg.optimizer_type == "adamw": + optimizer = AdamW(self._trainable_params, lr=lr) + elif opt_cfg.optimizer_type == "adamw8bit": + # noinspection PyUnresolvedReferences + from bitsandbytes.optim import AdamW8bit # noqa: PLC0415 + + optimizer = AdamW8bit(self._trainable_params, lr=lr) + else: + raise ValueError(f"Unknown optimizer type: {opt_cfg.optimizer_type}") + + lr_scheduler = self._create_scheduler(optimizer) + + # noinspection PyTypeChecker + self._optimizer, self._lr_scheduler = self._accelerator.prepare(optimizer, lr_scheduler) + + def _create_scheduler(self, optimizer: torch.optim.Optimizer) -> LRScheduler | None: + """Create learning rate scheduler based on config.""" + scheduler_type = self._config.optimization.scheduler_type + steps = self._config.optimization.steps + params = self._config.optimization.scheduler_params or {} + + if scheduler_type is None: + return None + + if scheduler_type == "linear": + scheduler = LinearLR( + optimizer, + start_factor=params.pop("start_factor", 1.0), + end_factor=params.pop("end_factor", 0.1), + total_iters=steps, + **params, + ) + elif scheduler_type == "cosine": + scheduler = CosineAnnealingLR( + optimizer, + T_max=steps, + eta_min=params.pop("eta_min", 0), + **params, + ) + elif scheduler_type == "cosine_with_restarts": + scheduler = CosineAnnealingWarmRestarts( + optimizer, + T_0=params.pop("T_0", steps // 4), + T_mult=params.pop("T_mult", 1), + eta_min=params.pop("eta_min", 5e-5), + **params, + ) + elif scheduler_type == "polynomial": + scheduler = PolynomialLR( + optimizer, + total_iters=steps, + power=params.pop("power", 1.0), + **params, + ) + elif scheduler_type == "step": + scheduler = StepLR( + optimizer, + step_size=params.pop("step_size", steps // 2), + gamma=params.pop("gamma", 0.1), + **params, + ) + elif scheduler_type == "constant": + scheduler = None + else: + raise ValueError(f"Unknown scheduler type: {scheduler_type}") + + return scheduler + + def _setup_accelerator(self) -> None: + """Initialize the Accelerator with the appropriate settings.""" + + # find_unused_parameters=True keeps DDP happy when LoRA targets a branch the forward + # pass skips (e.g. audio LoRA with `with_audio: false`, or short module patterns like + # "to_k" that match the audio branch unintentionally). It's a no-op for FSDP and + # single-GPU runs. The probing cost is paid only on the first step. + ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True) + + # All distributed setup (DDP/FSDP, number of processes, etc.) is controlled by + # the user's Accelerate configuration (accelerate config / accelerate launch). + self._accelerator = Accelerator( + mixed_precision=self._config.acceleration.mixed_precision_mode, + gradient_accumulation_steps=self._config.optimization.gradient_accumulation_steps, + kwargs_handlers=[ddp_kwargs], + ) + + if self._accelerator.num_processes > 1: + logger.info( + f"{self._accelerator.distributed_type.value} distributed training enabled " + f"with {self._accelerator.num_processes} processes" + ) + + local_batch = self._config.optimization.batch_size + global_batch = self._config.optimization.batch_size * self._accelerator.num_processes + logger.info(f"Local batch size: {local_batch}, global batch size: {global_batch}") + + # Log torch.compile status from Accelerate's dynamo plugin + is_compile_enabled = ( + hasattr(self._accelerator.state, "dynamo_plugin") and self._accelerator.state.dynamo_plugin.backend != "NO" + ) + if is_compile_enabled: + plugin = self._accelerator.state.dynamo_plugin + logger.info(f"🔥 torch.compile enabled via Accelerate: backend={plugin.backend}, mode={plugin.mode}") + + if self._accelerator.distributed_type == DistributedType.FSDP: + logger.warning( + "⚠️ FSDP + torch.compile is experimental and may hang on the first training iteration. " + "If this occurs, disable torch.compile by removing dynamo_config from your Accelerate config." + ) + + if self._accelerator.distributed_type == DistributedType.FSDP and self._config.acceleration.quantization: + logger.warning( + f"FSDP with quantization ({self._config.acceleration.quantization}) may have compatibility issues." + "Monitor training stability and consider disabling quantization if issues arise." + ) + + @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. + """ + self._optimizer.zero_grad(set_to_none=True) + free_gpu_memory() + + 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 + 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 + + @staticmethod + def _log_training_stats(stats: TrainingStats) -> None: + """Log training statistics.""" + stats_str = ( + "📊 Training Statistics:\n" + f" - Total time: {stats.total_time_seconds / 60:.1f} minutes\n" + f" - Training speed: {stats.steps_per_second:.2f} steps/second\n" + f" - Samples/second: {stats.samples_per_second:.2f}\n" + f" - Peak GPU memory: {stats.peak_gpu_memory_gb:.2f} GB" + ) + if stats.num_processes > 1: + stats_str += f"\n - Number of processes: {stats.num_processes}\n" + stats_str += f" - Global batch size: {stats.global_batch_size}" + logger.info(stats_str) + + def _save_checkpoint(self) -> Path | None: + """Save the model weights.""" + is_lora = self._config.model.training_mode == "lora" + is_fsdp = self._accelerator.distributed_type == DistributedType.FSDP + + # Prepare paths + save_dir = Path(self._config.output_dir) / "checkpoints" + prefix = "lora" if is_lora else "model" + filename = f"{prefix}_weights_step_{self._global_step:05d}.safetensors" + saved_weights_path = save_dir / filename + + # Get state dict (collective operation - all processes must participate) + self._accelerator.wait_for_everyone() + full_state_dict = self._accelerator.get_state_dict(self._transformer) + + if not IS_MAIN_PROCESS: + return None + + save_dir.mkdir(exist_ok=True, parents=True) + + # Determine save precision + save_dtype = torch.bfloat16 if self._config.checkpoints.precision == "bfloat16" else torch.float32 + + # For LoRA: extract only adapter weights; for full: use as-is + if is_lora: + unwrapped = self._accelerator.unwrap_model(self._transformer, keep_torch_compile=False) + # For FSDP, pass full_state_dict since model params aren't directly accessible + state_dict = get_peft_model_state_dict(unwrapped, state_dict=full_state_dict if is_fsdp else None) + + # Remove "base_model.model." prefix added by PEFT + state_dict = {k.replace("base_model.model.", "", 1): v for k, v in state_dict.items()} + + # Convert to ComfyUI-compatible format (add "diffusion_model." prefix) + state_dict = {f"diffusion_model.{k}": v for k, v in state_dict.items()} + + # Cast to configured precision + state_dict = {k: v.to(save_dtype) if isinstance(v, Tensor) else v for k, v in state_dict.items()} + + # Build metadata for safetensors file + metadata = self._build_checkpoint_metadata() + + # Save to disk with metadata + save_file(state_dict, saved_weights_path, metadata=metadata) + else: + # Cast to configured precision + full_state_dict = {k: v.to(save_dtype) if isinstance(v, Tensor) else v for k, v in full_state_dict.items()} + + # Save to disk + self._accelerator.save(full_state_dict, saved_weights_path) + + rel_path = saved_weights_path.relative_to(self._config.output_dir) + logger.info(f"💾 {prefix.capitalize()} weights for step {self._global_step} saved in {rel_path}") + + self._checkpoint_paths.append(saved_weights_path) + self._cleanup_checkpoints() + + self._save_training_state(save_dir) + + return saved_weights_path + + def _cleanup_checkpoints(self) -> None: + """Clean up old checkpoints.""" + if 0 < self._config.checkpoints.keep_last_n < len(self._checkpoint_paths): + checkpoints_to_remove = self._checkpoint_paths[: -self._config.checkpoints.keep_last_n] + for old_checkpoint in checkpoints_to_remove: + if old_checkpoint.exists(): + old_checkpoint.unlink() + logger.info(f"Removed old checkpoint: {old_checkpoint}") + self._checkpoint_paths = self._checkpoint_paths[-self._config.checkpoints.keep_last_n :] + + 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 + - "minimal": scheduler + RNG + step only + - "off": skip entirely + """ + if not IS_MAIN_PROCESS: + return + + mode = self._config.checkpoints.save_training_state + if mode == "off": + return + + is_fsdp = self._accelerator.distributed_type == DistributedType.FSDP + + optimizer_state = None + if mode == "full": + if is_fsdp: + logger.warning( + "⚠️ save_training_state='full' is not supported with FSDP. " + "Saving 'minimal' state (scheduler + RNG only)." + ) + else: + optimizer_state = self._optimizer.state_dict() + + state = TrainingState( + global_step=self._global_step, + config_fingerprint=ConfigFingerprint( + optimizer_type=self._config.optimization.optimizer_type, + scheduler_type=self._config.optimization.scheduler_type, + training_mode=self._config.model.training_mode, + lora_rank=self._config.lora.rank if self._config.lora is not None else None, + ), + rng_states=RngStates( + torch_state=torch.random.get_rng_state(), + cuda_state=torch.cuda.get_rng_state() if torch.cuda.is_available() else None, + ), + lr_scheduler_state_dict=self._lr_scheduler.state_dict() if self._lr_scheduler is not None else None, + optimizer_state_dict=optimizer_state, + wandb_run_id=self._wandb_run.id if self._wandb_run is not None else None, + ) + + state_path = save_dir / f"training_state_step_{self._global_step:05d}.pt" + tmp_path = state_path.with_suffix(".pt.tmp") + try: + torch.save(state.to_save_dict(), tmp_path) + except Exception: + if tmp_path.exists(): + tmp_path.unlink() + raise + tmp_path.rename(state_path) + + file_size_gb = state_path.stat().st_size / (1024**3) + if file_size_gb > 1.0 and not self._training_state_size_warned: + self._training_state_size_warned = True + logger.warning( + f"⚠️ Training state file is {file_size_gb:.1f} GB (full mode includes optimizer state). " + f'Set checkpoints.save_training_state="minimal" to save only scheduler/RNG/step (~few KB), ' + f'or "off" to disable entirely.' + ) + + if not self._training_state_paths or self._training_state_paths[-1] != state_path: + self._training_state_paths.append(state_path) + self._cleanup_training_states() + + rel_path = state_path.relative_to(self._config.output_dir) + logger.debug(f"Training state saved to {rel_path}") + + def _cleanup_training_states(self) -> None: + """Clean up old training state files, using the same keep_last_n as checkpoints.""" + keep_n = self._config.checkpoints.keep_last_n + if 0 < keep_n < len(self._training_state_paths): + to_remove = self._training_state_paths[:-keep_n] + for old_state in to_remove: + if old_state.exists(): + old_state.unlink() + logger.debug(f"Removed old training state: {old_state}") + self._training_state_paths = self._training_state_paths[-keep_n:] + + def _build_checkpoint_metadata(self) -> dict[str, str]: + """Build metadata dictionary for safetensors checkpoint. + Delegates to the training strategy to get strategy-specific metadata + that downstream inference pipelines may need. + Returns: + Dictionary of string key-value pairs for safetensors metadata. + Values are converted to strings for safetensors compatibility. + """ + raw_metadata = self._training_strategy.get_checkpoint_metadata() + # Convert all values to strings for safetensors compatibility + metadata = {k: str(v) for k, v in raw_metadata.items()} + if metadata: + logger.info(f"Saving checkpoint metadata: {metadata}") + return metadata + + def _save_config(self) -> None: + """Save the training configuration as a YAML file in the output directory.""" + if not IS_MAIN_PROCESS: + return + + config_path = Path(self._config.output_dir) / "training_config.yaml" + with open(config_path, "w") as f: + yaml.dump(self._config.model_dump(), f, default_flow_style=False, indent=2) + + logger.info(f"💾 Training configuration saved to: {config_path.relative_to(self._config.output_dir)}") + + def _init_wandb(self, resume_run_id: str | None = None) -> None: + """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 + + wandb_config = self._config.wandb + init_kwargs: dict[str, Any] = { + "project": wandb_config.project, + "entity": wandb_config.entity, + "name": Path(self._config.output_dir).name, + "tags": wandb_config.tags, + "config": self._config.model_dump(), + } + if resume_run_id is not None: + init_kwargs["id"] = resume_run_id + init_kwargs["resume"] = "must" + run = wandb.init(**init_kwargs) + self._wandb_run = run + + def _log_metrics(self, metrics: dict[str, float]) -> None: + """Log metrics to Weights & Biases.""" + if self._wandb_run is not None: + self._wandb_run.log(metrics) diff --git a/packages/ltx-trainer/src/ltx_trainer/training_state.py b/packages/ltx-trainer/src/ltx_trainer/training_state.py new file mode 100644 index 0000000000000000000000000000000000000000..45dd2df90407795e0db99a04e89bd38ce0974eb7 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_state.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +import torch +from pydantic import BaseModel, ConfigDict + + +class ConfigFingerprint(BaseModel): + optimizer_type: str + scheduler_type: str + training_mode: str + lora_rank: int | None = None + + +class RngStates(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + torch_state: torch.Tensor + cuda_state: torch.Tensor | None = None + + +class TrainingState(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + global_step: int + config_fingerprint: ConfigFingerprint + rng_states: RngStates + lr_scheduler_state_dict: dict[str, Any] | None = None + optimizer_state_dict: dict[str, Any] | None = None + wandb_run_id: str | None = None + + def to_save_dict(self) -> dict[str, Any]: + """Build dict suitable for torch.save -- recurses BaseModel sub-models, passes tensors/dicts through.""" + + def _convert(value: object) -> object: + if isinstance(value, BaseModel): + return {k: _convert(v) for k, v in value if v is not None} + return value + + return {k: _convert(v) for k, v in self if v is not None} + + @classmethod + def from_save_dict(cls, data: dict[str, Any]) -> TrainingState: + """Construct from torch.load output with Pydantic validation.""" + return cls( + global_step=data["global_step"], + config_fingerprint=ConfigFingerprint(**data["config_fingerprint"]), + rng_states=RngStates(**data["rng_states"]), + lr_scheduler_state_dict=data.get("lr_scheduler_state_dict"), + optimizer_state_dict=data.get("optimizer_state_dict"), + wandb_run_id=data.get("wandb_run_id"), + ) diff --git a/packages/ltx-trainer/src/ltx_trainer/training_strategies/__init__.py b/packages/ltx-trainer/src/ltx_trainer/training_strategies/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..71857c7d71bf79377c43bd54ef31468e1f517c49 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_strategies/__init__.py @@ -0,0 +1,90 @@ +"""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) [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, + VIDEO_SCALE_FACTORS, + ModelInputs, + 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 | FlexibleStrategyConfig + +__all__ = [ + "DEFAULT_FPS", + "VIDEO_SCALE_FACTORS", + "FlexibleStrategy", + "FlexibleStrategyConfig", + "ModelInputs", + "TextToVideoConfig", + "TextToVideoStrategy", + "TrainingStrategy", + "TrainingStrategyConfig", + "TrainingStrategyConfigBase", + "VideoToVideoConfig", + "VideoToVideoStrategy", + "get_training_strategy", +] + + +def get_training_strategy(config: TrainingStrategyConfig) -> TrainingStrategy: + """Factory function to create the appropriate training strategy. + The strategy is determined by the `name` field in the configuration. + Args: + config: Strategy-specific configuration with a `name` field + Returns: + The appropriate training strategy instance + Raises: + ValueError: If strategy name is not supported + 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__}") + + # 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 diff --git a/packages/ltx-trainer/src/ltx_trainer/training_strategies/base_strategy.py b/packages/ltx-trainer/src/ltx_trainer/training_strategies/base_strategy.py new file mode 100644 index 0000000000000000000000000000000000000000..d74083f0797262d95ac55172835d36c485af7fd3 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_strategies/base_strategy.py @@ -0,0 +1,249 @@ +"""Base class for training strategies. +This module defines the abstract base class that all training strategies must implement, +along with the base configuration class. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Literal + +import torch +from pydantic import BaseModel, ConfigDict, Field +from torch import Tensor + +from ltx_core.components.patchifiers import ( + AudioPatchifier, + VideoLatentPatchifier, + get_pixel_coords, +) +from ltx_core.model.transformer.modality import Modality +from ltx_core.types import AudioLatentShape, SpatioTemporalScaleFactors, VideoLatentShape +from ltx_trainer.timestep_samplers import TimestepSampler + +# Default frames per second for video missing in the FPS metadata +DEFAULT_FPS = 24 + +# VAE scale factors for LTX-2 +VIDEO_SCALE_FACTORS = SpatioTemporalScaleFactors.default() + + +class TrainingStrategyConfigBase(BaseModel): + """Base configuration class for training strategies. + All strategy-specific configuration classes should inherit from this. + """ + + model_config = ConfigDict(extra="forbid") + + name: Literal["text_to_video", "video_to_video", "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 | None + audio: Modality | None + + # Training targets (for loss computation) + video_targets: Tensor | None + audio_targets: Tensor | None + + # Masks for loss computation (True = compute loss for this token) + video_loss_mask: Tensor | None + audio_loss_mask: Tensor | None + + +class TrainingStrategy(ABC): + """Abstract base class for training strategies. + Each strategy encapsulates the logic for a specific training mode, + handling input preparation and loss computation. + """ + + def __init__(self, config: TrainingStrategyConfigBase): + """Initialize strategy with configuration. + Args: + config: Strategy-specific configuration + """ + self.config = config + self._video_patchifier = VideoLatentPatchifier(patch_size=1) + self._audio_patchifier = AudioPatchifier(patch_size=1) + + @abstractmethod + def prepare_training_inputs( + self, + batch: dict[str, Any], + timestep_sampler: TimestepSampler, + ) -> ModelInputs: + """Prepare training inputs from a raw data batch. + Args: + batch: Raw batch data from the dataset. Contains: + - "latents": Video latent data + - "conditions": Text embeddings with keys: + - "video_prompt_embeds": Already processed by embedding connectors + - "audio_prompt_embeds": Already processed by embedding connectors + - "prompt_attention_mask": Attention mask + - Additional keys depending on strategy (e.g., "ref_latents" for IC-LoRA) + timestep_sampler: Sampler for generating timesteps and noise + Returns: + ModelInputs containing Modality objects and training targets + """ + + @abstractmethod + def compute_loss( + self, + video_pred: Tensor, + audio_pred: Tensor | None, + inputs: ModelInputs, + ) -> Tensor: + """Compute the training loss. + Args: + video_pred: Video prediction from the transformer model + audio_pred: Audio prediction from the transformer model (None for video-only) + inputs: The prepared model inputs containing targets and masks + Returns: + Per-element loss tensor of shape [B,]. The trainer reduces to a scalar + before backward(). Returning unreduced loss enables per-sigma-bucket tracking. + """ + + def get_checkpoint_metadata(self) -> dict[str, Any]: + """Get strategy-specific metadata to include in checkpoint files. + Override this method in subclasses to add custom metadata, + e.g. any parameters that a downstream inference pipeline may need. + Returns: + Dictionary of metadata key-value pairs (values must be JSON-serializable) + """ + return {} + + def _get_video_positions( + self, + num_frames: int, + height: int, + width: int, + batch_size: int, + fps: float, + device: torch.device, + ) -> Tensor: + """Generate video position embeddings using ltx_core's native implementation. + Args: + num_frames: Number of latent frames + height: Latent height + width: Latent width + batch_size: Batch size + fps: Frames per second + device: Target device + Returns: + Position tensor of shape [B, 3, seq_len, 2] (float32) + """ + latent_coords = self._video_patchifier.get_patch_grid_bounds( + output_shape=VideoLatentShape( + frames=num_frames, + height=height, + width=width, + batch=batch_size, + channels=128, # Video latent channels + ), + device=device, + ) + + # Convert latent coords to pixel coords with causal fix + pixel_coords = get_pixel_coords( + latent_coords=latent_coords, + scale_factors=VIDEO_SCALE_FACTORS, + causal_fix=True, + ).float() + + # Scale temporal dimension by 1/fps to get time in seconds + pixel_coords[:, 0, ...] = pixel_coords[:, 0, ...] / fps + + return pixel_coords + + def _get_audio_positions( + self, + num_time_steps: int, + batch_size: int, + device: torch.device, + ) -> 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 + Returns: + Position tensor of shape [B, 1, num_time_steps, 2] + Note: + Audio latents should be in patchified format [B, T, C*F] = [B, T, 128] + where T is the number of time steps, C=8 channels, F=16 mel bins. + This matches the format produced by AudioPatchifier.patchify(). + """ + mel_bins = 16 + + return self._audio_patchifier.get_patch_grid_bounds( + output_shape=AudioLatentShape( + frames=num_time_steps, + mel_bins=mel_bins, + batch=batch_size, + channels=8, # Audio latent channels + ), + device=device, + ) + + @staticmethod + def _create_per_token_timesteps(conditioning_mask: Tensor, sampled_sigma: Tensor) -> Tensor: + """Create per-token timesteps based on conditioning mask. + Args: + conditioning_mask: Boolean mask of shape (batch_size, sequence_length), + where True = conditioning token (timestep=0), False = target token (use sigma) + sampled_sigma: Sampled sigma values of shape (batch_size,) or (batch_size, 1, 1) + Returns: + Timesteps tensor of shape [batch_size, sequence_length] + """ + # Expand to match conditioning mask shape [B, seq_len] + expanded_sigma = sampled_sigma.view(-1, 1).expand_as(conditioning_mask) + + # Conditioning tokens get 0, target tokens get the sampled sigma + return torch.where(conditioning_mask, torch.zeros_like(expanded_sigma), expanded_sigma) + + @staticmethod + def _create_first_frame_conditioning_mask( + batch_size: int, + sequence_length: int, + height: int, + width: int, + device: torch.device, + first_frame_conditioning_p: float = 0.0, + ) -> Tensor: + """Create conditioning mask for first frame conditioning. + Args: + batch_size: Batch size + sequence_length: Total sequence length + height: Latent height + width: Latent width + device: Target device + first_frame_conditioning_p: Probability of conditioning on the first frame + Returns: + Boolean mask where True indicates first frame tokens (if conditioning is enabled). + The conditioning decision is drawn independently per batch element so the training + signal across samples in a batch is i.i.d. + """ + conditioning_mask = torch.zeros(batch_size, sequence_length, dtype=torch.bool, device=device) + + if first_frame_conditioning_p > 0: + first_frame_end_idx = height * width + if first_frame_end_idx < sequence_length: + # Per-sample Bernoulli draw so each batch element is independently conditioned. + per_sample_condition = torch.rand(batch_size, device=device) < first_frame_conditioning_p + conditioning_mask[per_sample_condition, :first_frame_end_idx] = True + + return conditioning_mask diff --git a/packages/ltx-trainer/src/ltx_trainer/training_strategies/flexible.py b/packages/ltx-trainer/src/ltx_trainer/training_strategies/flexible.py new file mode 100644 index 0000000000000000000000000000000000000000..82d0d9ae5c9d69628e58b03035d5efe26db4650f --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_strategies/flexible.py @@ -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 diff --git a/packages/ltx-trainer/src/ltx_trainer/training_strategies/text_to_video.py b/packages/ltx-trainer/src/ltx_trainer/training_strategies/text_to_video.py new file mode 100644 index 0000000000000000000000000000000000000000..66cf2297caf597947fadb4d34e071850477f86f8 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_strategies/text_to_video.py @@ -0,0 +1,277 @@ +"""Text-to-video training strategy. +This strategy implements standard text-to-video generation training where: +- Only target latents are used (no reference videos) +- Standard noise application and loss computation +- Supports first frame conditioning +- Optionally supports joint audio-video training +""" + +from typing import Any, Literal + +import torch +from pydantic import Field +from torch import Tensor + +from ltx_core.model.transformer.modality import Modality +from ltx_trainer import logger +from ltx_trainer.timestep_samplers import TimestepSampler +from ltx_trainer.training_strategies.base_strategy import ( + DEFAULT_FPS, + ModelInputs, + TrainingStrategy, + TrainingStrategyConfigBase, +) + + +class TextToVideoConfig(TrainingStrategyConfigBase): + """Configuration for text-to-video training strategy.""" + + name: Literal["text_to_video"] = "text_to_video" + + first_frame_conditioning_p: float = Field( + default=0.1, + description="Probability of conditioning on the first frame during training", + ge=0.0, + le=1.0, + ) + + with_audio: bool = Field( + default=False, + description="Whether to include audio in training (joint audio-video generation)", + ) + + audio_latents_dir: str = Field( + default="audio_latents", + description="Directory name for audio latents when with_audio is True", + ) + + 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. + This strategy implements regular video generation training where: + - Only target latents are used (no reference videos) + - Standard noise application and loss computation + - Supports first frame conditioning + - Optionally supports joint audio-video training when with_audio=True + """ + + config: TextToVideoConfig + + def __init__(self, config: TextToVideoConfig): + """Initialize strategy with configuration. + Args: + config: Text-to-video configuration + """ + super().__init__(config) + + def prepare_training_inputs( + self, + batch: dict[str, Any], + timestep_sampler: TimestepSampler, + ) -> ModelInputs: + """Prepare inputs for text-to-video training.""" + # Get pre-encoded latents - dataset provides uniform non-patchified format [B, C, F, H, W] + latents = batch["latents"] + video_latents = latents["latents"] + + # Get video dimensions (assume same for all batch elements) + num_frames = latents["num_frames"][0].item() + height = latents["height"][0].item() + width = latents["width"][0].item() + + # Patchify latents: [B, C, F, H, W] -> [B, seq_len, C] + video_latents = self._video_patchifier.patchify(video_latents) + + # Handle FPS with backward compatibility + fps = latents.get("fps", None) + if fps is not None and not torch.all(fps == fps[0]): + logger.warning( + f"Different FPS values found in the batch. Found: {fps.tolist()}, using the first one: {fps[0].item()}" + ) + fps = fps[0].item() if fps is not None else DEFAULT_FPS + + # Get text embeddings (already processed by embedding connectors in trainer) + conditions = batch["conditions"] + video_prompt_embeds = conditions["video_prompt_embeds"] + audio_prompt_embeds = conditions["audio_prompt_embeds"] + prompt_attention_mask = conditions["prompt_attention_mask"] + + batch_size = video_latents.shape[0] + video_seq_len = video_latents.shape[1] + device = video_latents.device + + # Create conditioning mask (first frame conditioning) + video_conditioning_mask = self._create_first_frame_conditioning_mask( + batch_size=batch_size, + sequence_length=video_seq_len, + height=height, + width=width, + device=device, + first_frame_conditioning_p=self.config.first_frame_conditioning_p, + ) + + # Sample noise and sigmas + sigmas = timestep_sampler.sample_for(video_latents) + video_noise = torch.randn_like(video_latents) + + # Apply noise: noisy = (1 - sigma) * clean + sigma * noise + sigmas_expanded = sigmas.view(-1, 1, 1) + noisy_video = (1 - sigmas_expanded) * video_latents + sigmas_expanded * video_noise + + # For conditioning tokens, use clean latents + conditioning_mask_expanded = video_conditioning_mask.unsqueeze(-1) + noisy_video = torch.where(conditioning_mask_expanded, video_latents, noisy_video) + + # Compute video targets (velocity prediction) + video_targets = video_noise - video_latents + + # Create per-token timesteps + video_timesteps = self._create_per_token_timesteps(video_conditioning_mask, sigmas.squeeze()) + + # Generate video positions using ltx_core's native implementation + video_positions = self._get_video_positions( + num_frames=num_frames, + height=height, + width=width, + batch_size=batch_size, + fps=fps, + device=device, + ) + + # Create video Modality + video_modality = Modality( + enabled=True, + sigma=sigmas, + latent=noisy_video, + timesteps=video_timesteps, + positions=video_positions, + context=video_prompt_embeds, + context_mask=prompt_attention_mask, + ) + + # Video loss mask: True for tokens we want to compute loss on (non-conditioning tokens) + video_loss_mask = ~video_conditioning_mask + + # Handle audio if enabled + audio_modality = None + audio_targets = None + audio_loss_mask = None + + if self.config.with_audio: + audio_modality, audio_targets, audio_loss_mask = self._prepare_audio_inputs( + batch=batch, + sigmas=sigmas, + audio_prompt_embeds=audio_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + batch_size=batch_size, + device=device, + ) + + return ModelInputs( + video=video_modality, + audio=audio_modality, + video_targets=video_targets, + audio_targets=audio_targets, + video_loss_mask=video_loss_mask, + audio_loss_mask=audio_loss_mask, + ) + + def _prepare_audio_inputs( + self, + batch: dict[str, Any], + sigmas: Tensor, + audio_prompt_embeds: Tensor, + prompt_attention_mask: Tensor, + batch_size: int, + device: torch.device, + ) -> tuple[Modality, Tensor, Tensor]: + """Prepare audio inputs for joint audio-video training. + Args: + batch: Raw batch data containing audio_latents + sigmas: Sampled sigma values (same as video) + audio_prompt_embeds: Audio context embeddings + prompt_attention_mask: Attention mask for context + batch_size: Batch size + device: Target device + Returns: + Tuple of (audio_modality, audio_targets, audio_loss_mask) + """ + # Get audio latents - dataset provides uniform non-patchified format [B, C, T, F] + audio_data = batch["audio_latents"] + audio_latents = audio_data["latents"] + + # Patchify audio latents: [B, C, T, F] -> [B, T, C*F] + audio_latents = self._audio_patchifier.patchify(audio_latents) + + audio_seq_len = audio_latents.shape[1] + + # Sample audio noise + audio_noise = torch.randn_like(audio_latents) + + # Apply noise to audio (same sigma as video) + sigmas_expanded = sigmas.view(-1, 1, 1) + noisy_audio = (1 - sigmas_expanded) * audio_latents + sigmas_expanded * audio_noise + + # Compute audio targets + audio_targets = audio_noise - audio_latents + + # Audio timesteps: all tokens use the sampled sigma (no conditioning mask) + audio_timesteps = sigmas.view(-1, 1).expand(-1, audio_seq_len) + + # Generate audio positions + audio_positions = self._get_audio_positions( + num_time_steps=audio_seq_len, + batch_size=batch_size, + device=device, + ) + + # Create audio Modality + audio_modality = Modality( + enabled=True, + latent=noisy_audio, + sigma=sigmas, + timesteps=audio_timesteps, + positions=audio_positions, + context=audio_prompt_embeds, + context_mask=prompt_attention_mask, + ) + + # Audio loss mask: all tokens contribute to loss (no conditioning) + audio_loss_mask = torch.ones(batch_size, audio_seq_len, dtype=torch.bool, device=device) + + return audio_modality, audio_targets, audio_loss_mask + + def compute_loss( + self, + video_pred: Tensor, + audio_pred: Tensor | None, + inputs: ModelInputs, + ) -> Tensor: + """Compute masked MSE loss for video and optionally audio. Returns [B,].""" + # Video loss: per-element mean over (seq, channels), [B,] + video_loss = (video_pred - inputs.video_targets).pow(2) + video_loss_mask = inputs.video_loss_mask.unsqueeze(-1).float() + masked = video_loss.mul(video_loss_mask) + video_loss = masked.mean(dim=[-2, -1]) / video_loss_mask.mean(dim=[-2, -1]).clamp(min=1e-8) + + # If no audio, return video loss only + if not self.config.with_audio or audio_pred is None or inputs.audio_targets is None: + return video_loss + + # Audio loss: per-element mean over (seq, channels), [B,] + audio_loss = (audio_pred - inputs.audio_targets).pow(2).mean(dim=[-2, -1]) + + # Combined loss [B,] + return video_loss + audio_loss diff --git a/packages/ltx-trainer/src/ltx_trainer/training_strategies/video_to_video.py b/packages/ltx-trainer/src/ltx_trainer/training_strategies/video_to_video.py new file mode 100644 index 0000000000000000000000000000000000000000..2d133b3af3baa35f772c373bccea023aa3df5b33 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/training_strategies/video_to_video.py @@ -0,0 +1,294 @@ +"""Video-to-video training strategy for IC-LoRA. +This strategy implements training with reference video conditioning where: +- Reference latents (clean) are concatenated with target latents (noised) +- Video coordinates handle both reference and target sequences +- Loss is computed only on the target portion +""" + +from typing import Any, Literal + +import torch +from pydantic import Field +from torch import Tensor + +from ltx_core.model.transformer.modality import Modality +from ltx_trainer import logger +from ltx_trainer.timestep_samplers import TimestepSampler +from ltx_trainer.training_strategies.base_strategy import ( + DEFAULT_FPS, + ModelInputs, + TrainingStrategy, + TrainingStrategyConfigBase, +) + + +class VideoToVideoConfig(TrainingStrategyConfigBase): + """Configuration for video-to-video (IC-LoRA) training strategy.""" + + name: Literal["video_to_video"] = "video_to_video" + + first_frame_conditioning_p: float = Field( + default=0.1, + description="Probability of conditioning on the first frame during training", + ge=0.0, + le=1.0, + ) + + reference_latents_dir: str = Field( + default="reference_latents", + description="Directory name for latents of reference videos", + ) + + 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. + This strategy implements training with reference video conditioning where: + - Reference latents (clean) are concatenated with target latents (noised) + - Video coordinates handle both reference and target sequences + - Loss is computed only on the target portion + Attributes: + reference_downscale_factor: The inferred downscale factor of reference videos. + This is computed from the first batch and cached for metadata export. + """ + + config: VideoToVideoConfig + reference_downscale_factor: int | None + + def __init__(self, config: VideoToVideoConfig): + """Initialize strategy with configuration. + Args: + config: Video-to-video configuration + """ + super().__init__(config) + self.reference_downscale_factor = None # Will be inferred from first batch + + def prepare_training_inputs( # noqa: PLR0915 + self, + batch: dict[str, Any], + timestep_sampler: TimestepSampler, + ) -> ModelInputs: + """Prepare inputs for IC-LoRA training with reference videos.""" + # Get pre-encoded latents - dataset provides uniform non-patchified format [B, C, F, H, W] + latents = batch["latents"] + target_latents = latents["latents"] + ref_latents = batch["ref_latents"]["latents"] + + # Get dimensions + num_frames = latents["num_frames"][0].item() + height = latents["height"][0].item() + width = latents["width"][0].item() + + ref_latents_info = batch["ref_latents"] + ref_frames = ref_latents_info["num_frames"][0].item() + ref_height = ref_latents_info["height"][0].item() + ref_width = ref_latents_info["width"][0].item() + + # Infer reference downscale factor from dimension ratios + # This allows training with downscaled reference videos for efficiency + reference_downscale_factor = self._infer_reference_downscale_factor( + target_height=height, + target_width=width, + ref_height=ref_height, + ref_width=ref_width, + ) + + # Cache the scale factor for metadata export (only on first batch) + if self.reference_downscale_factor is None: + self.reference_downscale_factor = reference_downscale_factor + elif self.reference_downscale_factor != reference_downscale_factor: + raise ValueError( + f"Inconsistent reference downscale factor across batches. " + f"First batch had factor={self.reference_downscale_factor}, " + f"but current batch has factor={reference_downscale_factor}. " + f"All training samples must use the same reference/target resolution ratio." + ) + + # Patchify latents: [B, C, F, H, W] -> [B, seq_len, C] + target_latents = self._video_patchifier.patchify(target_latents) + ref_latents = self._video_patchifier.patchify(ref_latents) + + # Handle FPS + fps = latents.get("fps", None) + if fps is not None and not torch.all(fps == fps[0]): + logger.warning( + f"Different FPS values found in the batch. Found: {fps.tolist()}, using the first one: {fps[0].item()}" + ) + fps = fps[0].item() if fps is not None else DEFAULT_FPS + + # Get text embeddings (already processed by embedding connectors in trainer) + # Video-to-video uses only video embeddings + conditions = batch["conditions"] + prompt_embeds = conditions["video_prompt_embeds"] + prompt_attention_mask = conditions["prompt_attention_mask"] + + batch_size = target_latents.shape[0] + ref_seq_len = ref_latents.shape[1] + target_seq_len = target_latents.shape[1] + device = target_latents.device + + # Create conditioning mask + # Reference tokens are always conditioning (timestep=0) + ref_conditioning_mask = torch.ones(batch_size, ref_seq_len, dtype=torch.bool, device=device) + + # Target tokens: check for first frame conditioning + target_conditioning_mask = self._create_first_frame_conditioning_mask( + batch_size=batch_size, + sequence_length=target_seq_len, + height=height, + width=width, + device=device, + first_frame_conditioning_p=self.config.first_frame_conditioning_p, + ) + + # Combined conditioning mask + conditioning_mask = torch.cat([ref_conditioning_mask, target_conditioning_mask], dim=1) + + # Sample noise and sigmas for target + sigmas = timestep_sampler.sample_for(target_latents) + noise = torch.randn_like(target_latents) + sigmas_expanded = sigmas.view(-1, 1, 1) + + # Apply noise to target + noisy_target = (1 - sigmas_expanded) * target_latents + sigmas_expanded * noise + + # For first frame conditioning in target, use clean latents + target_conditioning_mask_expanded = target_conditioning_mask.unsqueeze(-1) + noisy_target = torch.where(target_conditioning_mask_expanded, target_latents, noisy_target) + + # Targets for loss computation (velocity prediction) - only for target portion + targets = noise - target_latents + + # Concatenate reference (clean) and target (noisy) + combined_latents = torch.cat([ref_latents, noisy_target], dim=1) + + # Create per-token timesteps + timesteps = self._create_per_token_timesteps(conditioning_mask, sigmas.squeeze()) + + # Generate positions for reference and target separately, then concatenate + ref_positions = self._get_video_positions( + num_frames=ref_frames, + height=ref_height, + width=ref_width, + batch_size=batch_size, + fps=fps, + device=device, + ) + + # Scale reference positions to match target coordinate space + # This maps ref positions from (0, ref_H, ref_W) to (0, target_H, target_W) + # Position tensor shape: [B, 3, seq_len, 2] where dim 1 is (time, height, width) + if reference_downscale_factor != 1: + ref_positions = ref_positions.clone() + ref_positions[:, 1, ...] *= reference_downscale_factor # height axis + ref_positions[:, 2, ...] *= reference_downscale_factor # width axis + # Time axis (index 0) remains unchanged + + target_positions = self._get_video_positions( + num_frames=num_frames, + height=height, + width=width, + batch_size=batch_size, + fps=fps, + device=device, + ) + + # Concatenate positions along sequence dimension + positions = torch.cat([ref_positions, target_positions], dim=2) + + # Create video Modality + video_modality = Modality( + enabled=True, + latent=combined_latents, + sigma=sigmas, + timesteps=timesteps, + positions=positions, + context=prompt_embeds, + context_mask=prompt_attention_mask, + ) + + # Loss mask: only compute loss on non-conditioning target tokens + # Reference tokens: all False (no loss) + # Target tokens: True where not conditioning + ref_loss_mask = torch.zeros(batch_size, ref_seq_len, dtype=torch.bool, device=device) + target_loss_mask = ~target_conditioning_mask + video_loss_mask = torch.cat([ref_loss_mask, target_loss_mask], dim=1) + + return ModelInputs( + video=video_modality, + audio=None, + video_targets=targets, + audio_targets=None, + video_loss_mask=video_loss_mask, + audio_loss_mask=None, + ) + + def compute_loss( + self, + video_pred: Tensor, + _audio_pred: Tensor | None, + inputs: ModelInputs, + ) -> Tensor: + """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) + loss_mask = target_loss_mask.unsqueeze(-1).float() + masked = loss.mul(loss_mask) + return masked.mean(dim=[-2, -1]) / loss_mask.mean(dim=[-2, -1]).clamp(min=1e-8) + + def get_checkpoint_metadata(self) -> dict[str, Any]: + """Get metadata for checkpoint files.""" + metadata: dict[str, Any] = {} + # Always include reference_downscale_factor for IC-LoRAs so inference + # pipelines know the expected scale factor for reference videos. + if self.reference_downscale_factor is not None: + metadata["reference_downscale_factor"] = self.reference_downscale_factor + return metadata + + @staticmethod + def _infer_reference_downscale_factor( + target_height: int, + target_width: int, + ref_height: int, + ref_width: int, + ) -> int: + """Infer the reference downscale factor from target and reference dimensions.""" + # If dimensions match, no scaling needed + if target_height == ref_height and target_width == ref_width: + return 1 + + # Calculate scale factors for each dimension + if target_height % ref_height != 0 or target_width % ref_width != 0: + raise ValueError( + f"Target dimensions ({target_height}x{target_width}) must be exact multiples " + f"of reference dimensions ({ref_height}x{ref_width})" + ) + + scale_h = target_height // ref_height + scale_w = target_width // ref_width + + if scale_h != scale_w: + raise ValueError( + f"Reference scale must be uniform. Got height scale {scale_h} and width scale {scale_w}. " + f"Target: {target_height}x{target_width}, Reference: {ref_height}x{ref_width}" + ) + + if scale_h < 1: + raise ValueError( + f"Reference dimensions ({ref_height}x{ref_width}) cannot be larger than " + f"target dimensions ({target_height}x{target_width})" + ) + + return scale_h diff --git a/packages/ltx-trainer/src/ltx_trainer/utils.py b/packages/ltx-trainer/src/ltx_trainer/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..85bf1ddf10f1cf370beb102bad19bf2b7f791985 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/utils.py @@ -0,0 +1,88 @@ +import io +from pathlib import Path + +import numpy as np +import torch +from PIL import ExifTags, Image, ImageCms, ImageOps +from PIL.Image import Image as PilImage + + +def open_image_as_srgb(image_path: str | Path | io.BytesIO) -> PilImage: + """ + Opens an image file, applies rotation (if it's set in metadata) and converts it + to the sRGB color space respecting the original image color space . + Args: + image_path: Path to the image file + Returns: + PIL Image in sRGB color space + """ + exif_colorspace_srgb = 1 + + with Image.open(image_path) as img_raw: + img = ImageOps.exif_transpose(img_raw) + + input_icc_profile = img.info.get("icc_profile") + + # Try to convert to sRGB if the image has ICC profile metadata + srgb_profile = ImageCms.createProfile(colorSpace="sRGB") + if input_icc_profile is not None: + input_profile = ImageCms.ImageCmsProfile(io.BytesIO(input_icc_profile)) + srgb_img = ImageCms.profileToProfile(img, input_profile, srgb_profile, outputMode="RGB") + else: + # Try fall back to checking EXIF + exif_data = img.getexif() + if exif_data is not None: + # Assume sRGB if no ICC profile and EXIF has no ColorSpace tag + color_space_value = exif_data.get(ExifTags.Base.ColorSpace.value) + if color_space_value is not None and color_space_value != exif_colorspace_srgb: + raise ValueError( + "Image has colorspace tag in EXIF but it isn't set to sRGB," + " conversion is not supported." + f" EXIF ColorSpace tag value is {color_space_value}", + ) + + srgb_img = img.convert("RGB") + + # Set sRGB profile in metadata since now the image is assumed to be in sRGB. + srgb_profile_data = ImageCms.ImageCmsProfile(srgb_profile).tobytes() + srgb_img.info["icc_profile"] = srgb_profile_data + + return srgb_img + + +def save_image(image_tensor: torch.Tensor, output_path: Path | str) -> None: + """Save an image tensor to a file. + Args: + image_tensor: Image tensor of shape [C, H, W] or [C, 1, H, W] in range [0, 1] or [0, 255]. + C must be 3 (RGB). + output_path: Path to save the image (any PIL-supported format, e.g., .png or .jpg) + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Handle [C, 1, H, W] format (single frame from video tensor) + if image_tensor.ndim == 4: + # Squeeze frame dimension: [C, 1, H, W] -> [C, H, W] + if image_tensor.shape[1] == 1: + image_tensor = image_tensor.squeeze(1) + else: + raise ValueError(f"Expected single-frame tensor with shape [C, 1, H, W], got shape {image_tensor.shape}") + + if image_tensor.ndim != 3: + raise ValueError(f"Expected 3D tensor [C, H, W], got {image_tensor.ndim}D tensor") + + if image_tensor.shape[0] != 3: + raise ValueError(f"Expected 3 channels (RGB), got {image_tensor.shape[0]} channels") + + # Normalize to [0, 255] uint8 + if torch.is_floating_point(image_tensor) and image_tensor.max() <= 1.0: + image_tensor = image_tensor * 255 + + # Clamp to valid uint8 range to prevent overflow + image_tensor = image_tensor.clamp(0, 255) + + # [C, H, W] -> [H, W, C] + image_np: np.ndarray = image_tensor.permute(1, 2, 0).to(torch.uint8).cpu().numpy() + + # Save using PIL + Image.fromarray(image_np).save(output_path) diff --git a/packages/ltx-trainer/src/ltx_trainer/validation_runner.py b/packages/ltx-trainer/src/ltx_trainer/validation_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..8f54a758221377971e340c7b05da0951de9649f6 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/validation_runner.py @@ -0,0 +1,1216 @@ +"""Self-contained validation runner for LTX-2 training. +Encapsulates all validation concerns: model loading, embedding caching, +conditioning media encoding, sample generation (denoising + decoding), +output saving, and W&B logging. +The trainer only needs to call ``run()`` with the current transformer state. +""" + +import os +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import torch +from einops import rearrange +from torch import Tensor +from torchvision.transforms import InterpolationMode +from torchvision.transforms import functional as TF # noqa: N812 +from torchvision.transforms.functional import to_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 +from ltx_core.components.schedulers import LTX2Scheduler +from ltx_core.conditioning.types.latent_cond import VideoConditionByLatentIndex +from ltx_core.conditioning.types.mask_cond import VideoConditionByMask +from ltx_core.conditioning.types.reference_video_cond import VideoConditionByReferenceLatent +from ltx_core.guidance.perturbations import ( + BatchedPerturbationConfig, + Perturbation, + PerturbationConfig, + PerturbationType, +) +from ltx_core.model.audio_vae.audio_vae import encode_audio as ltx_encode_audio +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 ( + Audio, + AudioLatentShape, + LatentState, + SpatioTemporalScaleFactors, + VideoLatentShape, + VideoPixelShape, +) +from ltx_trainer import logger +from ltx_trainer.config import ValidationConfig, ValidationSample +from ltx_trainer.gpu_utils import free_gpu_memory_context +from ltx_trainer.model_loader import ( + load_audio_vae_decoder, + load_audio_vae_encoder, + load_embeddings_processor, + load_text_encoder, + load_video_vae_decoder, + load_video_vae_encoder, + load_vocoder, +) +from ltx_trainer.progress import SamplingContext, TrainingProgress +from ltx_trainer.utils import open_image_as_srgb, save_image +from ltx_trainer.video_utils import read_video, save_video + +if TYPE_CHECKING: + from ltx_core.model.transformer import LTXModel + +VIDEO_SCALE_FACTORS = SpatioTemporalScaleFactors.default() +_DEFAULT_TILING = TilingConfig( + spatial_config=SpatialTilingConfig(tile_size_in_pixels=192, tile_overlap_in_pixels=64), + temporal_config=TemporalTilingConfig(tile_size_in_frames=48, tile_overlap_in_frames=24), +) + + +def _local_rank_device() -> torch.device: + """Per-rank CUDA device for early init (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. + """ + if not torch.cuda.is_available(): + return torch.device("cpu") + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + return torch.device(f"cuda:{local_rank}") + + +# ============================================================================= +# Data classes +# ============================================================================= + + +@dataclass +class CachedPromptEmbeddings: + """Pre-computed text embeddings for a validation prompt.""" + + video_context_positive: Tensor + audio_context_positive: Tensor + video_context_negative: Tensor | None = None + audio_context_negative: Tensor | None = None + + +@dataclass +class CachedConditionMedia: + """Pre-encoded media for a single validation condition.""" + + latent: Tensor + pixels: Tensor | None = None + mask: Tensor | None = None + + +@dataclass +class CachedSampleMedia: + """Pre-encoded conditioning media for one validation sample. + Keyed by condition index (position in the sample's conditions list). + """ + + conditions: dict[int, CachedConditionMedia] = field(default_factory=dict) + + +# ============================================================================= +# ValidationRunner +# ============================================================================= + + +class ValidationRunner: + """Self-contained validation: loads models, caches media, generates samples. + Lifecycle: + 1. ``__init__``: loads text encoder + embeddings processor, caches prompt + embeddings, unloads both. Loads VAE encoder, encodes conditioning media, + unloads. Loads VAE decoder / audio decoder / vocoder and keeps them on CPU. + 2. ``run()``: called at each validation step with the current transformer. + Generates all samples, saves outputs, returns file paths. + """ + + def __init__( + self, + config: ValidationConfig, + model_path: str | Path, + text_encoder_path: str | Path | None, + load_text_encoder_in_8bit: bool = False, + ): + self._config = config + self._model_path = Path(model_path) + + self._video_patchifier = VideoLatentPatchifier(patch_size=1) + self._audio_patchifier = AudioPatchifier(patch_size=1) + + self._cached_embeddings = self._cache_prompt_embeddings(text_encoder_path, load_text_encoder_in_8bit) + self._cached_media = self._encode_conditioning_media() + self._load_decoder_components() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @torch.no_grad() + @free_gpu_memory_context(after=True) + def run( + self, + transformer: "LTXModel", + step: int, + output_dir: Path, + device: torch.device, + progress: TrainingProgress, + wandb_run: object | None = None, + work_items: list[tuple[int, bool]] | None = None, + ) -> list[tuple[int, Path]]: + """Generate validation samples, save outputs, and optionally log to W&B. + Args: + work_items: Optional list of ``(sample_index, save_output)`` tuples for distributed + validation. Each rank passes its assigned indices so prompts are split across + ranks. ``save_output=False`` runs the generation but skips writing to disk; this + is used for FSDP padding so every rank executes the same number of forwards and + collective ops stay aligned. When ``None``, all samples are generated and saved. + Returns: + List of ``(sample_index, path)`` tuples for samples that were saved. Callers can + ``gather_object`` and sort by index to reconstruct the global ordering. + """ + samples = self._config.samples + if not samples: + return [] + + if work_items is None: + work_items = [(i, True) for i in range(len(samples))] + if not work_items: + return [] + + inference_steps = self._config.inference_steps + sampling_ctx = progress.start_sampling(num_prompts=len(work_items), num_steps=inference_steps) + + samples_dir = output_dir / "samples" + samples_dir.mkdir(exist_ok=True, parents=True) + + results: list[tuple[int, Path]] = [] + + for local_i, (sample_idx, save_output) in enumerate(work_items): + sample = samples[sample_idx] + sampling_ctx.start_video(local_i) + + cached_embeddings = self._cached_embeddings[sample_idx] if self._cached_embeddings else None + cached_media = self._cached_media[sample_idx] if self._cached_media else CachedSampleMedia() + + video, audio = self._generate_sample( + sample=sample, + cached_embeddings=cached_embeddings, + cached_media=cached_media, + transformer=transformer, + device=device, + sampling_ctx=sampling_ctx, + ) + + if not save_output: + continue + + dims = sample.video_dims or self._config.video_dims + num_frames = dims[2] + ext = "png" if num_frames == 1 else "mp4" + out_path = samples_dir / f"step_{step:06d}_{sample_idx + 1}.{ext}" + + if video is not None: + if num_frames == 1: + save_image(video, out_path) + else: + save_video( + video_tensor=video, + output_path=out_path, + fps=self._config.frame_rate, + audio=audio, + audio_sample_rate=self._vocoder.output_sampling_rate if audio is not None else None, + video_format="CFHW", + ) + results.append((sample_idx, out_path)) + elif audio is not None: + out_path = out_path.with_suffix(".wav") + self._save_audio(audio, out_path) + results.append((sample_idx, out_path)) + + sampling_ctx.cleanup() + + rel_path = samples_dir.relative_to(output_dir) + logger.info(f"🎥 Validation samples for step {step} saved in {rel_path}") + + if wandb_run is not None and results: + self.log_to_wandb(wandb_run, [p for _, p in results], step) + + return results + + # --- Initialization: caching --- + + @torch.no_grad() + @free_gpu_memory_context(after=True) + def _cache_prompt_embeddings( + self, + text_encoder_path: str | Path | None, + load_in_8bit: bool, + ) -> list[CachedPromptEmbeddings]: + """Load text encoder, encode all validation prompts, cache on CPU, then unload.""" + prompts = [s.prompt for s in self._config.samples] + if not prompts: + return [] + + init_device = _local_rank_device() + + logger.debug("Loading text encoder for validation embedding caching...") + text_encoder = load_text_encoder( + gemma_model_path=text_encoder_path, device=init_device, dtype=torch.bfloat16, load_in_8bit=load_in_8bit + ) + + logger.debug("Loading embeddings processor for validation embedding caching...") + embeddings_processor = load_embeddings_processor( + checkpoint_path=self._model_path, device=init_device, dtype=torch.bfloat16 + ) + + logger.info(f"Pre-computing embeddings for {len(prompts)} validation prompts...") + cached: list[CachedPromptEmbeddings] = [] + neg_hs, neg_mask = text_encoder.encode([self._config.negative_prompt])[0] + neg_out = embeddings_processor.process_hidden_states(neg_hs, neg_mask) + + for prompt in prompts: + pos_hs, pos_mask = text_encoder.encode([prompt])[0] + pos_out = embeddings_processor.process_hidden_states(pos_hs, pos_mask) + + cached.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 + ), + ) + ) + + del text_encoder, embeddings_processor + logger.debug("Validation prompt embeddings cached. Text encoder unloaded.") + return cached + + @torch.no_grad() + @free_gpu_memory_context(after=True) + def _encode_conditioning_media(self) -> list[CachedSampleMedia]: + """Load VAE encoders, encode all conditioning media, cache on CPU, then unload.""" + samples = self._config.samples + if not samples: + return [] + + def _cond_needs_video_encoder(cond: object) -> bool: + if getattr(cond, "type", None) in ("first_frame", "video_to_audio", "spatial_crop"): + return True + return getattr(cond, "video", None) is not None + + def _cond_needs_audio_encoder(cond: object) -> bool: + if getattr(cond, "type", None) == "audio_to_video": + return True + return getattr(cond, "type", None) in ("prefix", "suffix", "mask", "reference") and ( + getattr(cond, "audio", None) is not None + ) + + needs_video_encoder = any(_cond_needs_video_encoder(c) for s in samples for c in s.conditions) + needs_audio_encoder = any(_cond_needs_audio_encoder(c) for s in samples for c in s.conditions) + + if not needs_video_encoder and not needs_audio_encoder: + return [CachedSampleMedia() for _ in samples] + + device = _local_rank_device() + vae_encoder = None + audio_encoder = None + + if needs_video_encoder: + logger.debug("Loading VAE encoder for validation media encoding...") + vae_encoder = load_video_vae_encoder(self._model_path, device="cpu", dtype=torch.bfloat16) + if needs_audio_encoder: + logger.debug("Loading audio VAE encoder for validation media encoding...") + audio_encoder = load_audio_vae_encoder(self._model_path, device="cpu", dtype=torch.bfloat16) + + logger.info(f"Pre-encoding conditioning media for {len(samples)} validation samples...") + cached: list[CachedSampleMedia] = [] + + for sample in samples: + sample_dims = sample.video_dims or self._config.video_dims + cached.append(self._encode_sample_conditions(sample, sample_dims, vae_encoder, audio_encoder, device)) + + del vae_encoder, audio_encoder + logger.info("Validation conditioning media cached on CPU.") + return cached + + def _encode_sample_conditions( + self, + sample: ValidationSample, + dims: tuple[int, int, int], + vae_encoder: torch.nn.Module | None, + audio_encoder: torch.nn.Module | None, + device: torch.device, + ) -> CachedSampleMedia: + """Encode all conditioning media for a single validation sample.""" + s_width, s_height, s_num_frames = dims + sample_media = CachedSampleMedia() + + for cond_idx, cond in enumerate(sample.conditions): + if cond.type == "first_frame": + image = self._load_first_frame(Path(cond.image_or_video)) + latent = self._encode_image(image, s_height, s_width, vae_encoder, device) + sample_media.conditions[cond_idx] = CachedConditionMedia(latent=latent) + + elif cond.type == "reference" and cond.video is not None: + ref_video, _ = read_video(cond.video, max_frames=s_num_frames) + preprocessed, pixels = self._preprocess_reference( + ref_video, s_height, s_width, cond.downscale_factor, cond.temporal_scale_factor + ) + latent = self._encode_video(preprocessed, vae_encoder, device) + sample_media.conditions[cond_idx] = CachedConditionMedia( + latent=latent, pixels=pixels if cond.include_in_output else None + ) + + elif cond.type == "reference" and getattr(cond, "audio", None) is not None: + video_duration = s_num_frames / self._config.frame_rate + latent = self._encode_audio(cond.audio, audio_encoder, device, max_duration=video_duration) + sample_media.conditions[cond_idx] = CachedConditionMedia(latent=latent) + + elif cond.type in ("prefix", "suffix", "mask", "spatial_crop", "video_to_audio"): + latent = self._encode_temporal_condition_media( + cond=cond, + target_width=s_width, + target_height=s_height, + max_frames=s_num_frames, + vae_encoder=vae_encoder, + audio_encoder=audio_encoder, + device=device, + ) + mask_tensor = None + if latent is not None and cond.type == "mask" and hasattr(cond, "mask") and cond.mask is not None: + if getattr(cond, "audio", None) is not None: + mask_tensor = self._load_audio_mask(cond.mask, s_num_frames, self._config.frame_rate) + else: + mask_tensor = self._load_and_downsample_mask(cond.mask, s_width, s_height, s_num_frames) + if latent is not None: + sample_media.conditions[cond_idx] = CachedConditionMedia(latent=latent, mask=mask_tensor) + + elif cond.type == "audio_to_video": + video_duration = s_num_frames / self._config.frame_rate + latent = self._encode_audio(cond.audio, audio_encoder, device, max_duration=video_duration) + sample_media.conditions[cond_idx] = CachedConditionMedia(latent=latent) + + return sample_media + + def _load_decoder_components(self) -> None: + """Load VAE decoder, audio decoder, and vocoder. Kept on CPU until generation.""" + self._vae_decoder = None + needs_video_decoder = self._config.generate_video or any( + c.type == "video_to_audio" for s in self._config.samples for c in s.conditions + ) + if needs_video_decoder: + logger.debug("Loading video VAE decoder for validation...") + self._vae_decoder = load_video_vae_decoder(self._model_path, device="cpu", dtype=torch.bfloat16) + if self._vae_decoder is not None: + self._vae_decoder.requires_grad_(False) + + self._audio_decoder = None + self._vocoder = None + needs_audio_decoder = self._config.generate_audio or any( + c.type == "audio_to_video" for s in self._config.samples for c in s.conditions + ) + if needs_audio_decoder: + logger.debug("Loading audio decoder and vocoder for validation...") + self._audio_decoder = load_audio_vae_decoder(self._model_path, device="cpu", dtype=torch.bfloat16) + if self._audio_decoder is not None: + self._audio_decoder.requires_grad_(False) + self._vocoder = load_vocoder(self._model_path, device="cpu", dtype=torch.bfloat16) + if self._vocoder is not None: + self._vocoder.requires_grad_(False) + + def _encode_temporal_condition_media( + self, + cond: object, + *, + target_width: int, + target_height: int, + max_frames: int, + vae_encoder: torch.nn.Module | None, + audio_encoder: torch.nn.Module | None, + device: torch.device, + ) -> Tensor | None: + """Encode media for prefix/suffix/mask/spatial_crop/frozen-video conditions.""" + video_path = getattr(cond, "video", None) + audio_path = getattr(cond, "audio", None) + if video_path is None and audio_path is not None: + max_dur = getattr(cond, "duration", None) + if max_dur is None and cond.type == "mask": + max_dur = max_frames / self._config.frame_rate + return self._encode_audio( + audio_path, + audio_encoder, + device, + max_duration=max_dur, + from_end=cond.type == "suffix", + ) + if video_path is None: + return None + + video, _ = read_video(video_path, max_frames=max_frames) + video = self._resize_and_center_crop(video, target_height, target_width) + requested_frames = getattr(cond, "num_frames", None) + + if cond.type == "suffix" and requested_frames is not None: + # The VAE encoder has temporal receptive fields, so encoding a short + # suffix clip independently produces different latents than encoding the + # full video. Encode the full clip and extract the last N latent frames. + video = rearrange(video, "f c h w -> 1 c f h w") + valid_frames = (video.shape[2] - 1) // 8 * 8 + 1 + video = video[:, :, :valid_frames] + latent = self._encode_video(video * 2.0 - 1.0, vae_encoder, device) + num_suffix_latent_frames = requested_frames // 8 + return latent[:, :, -num_suffix_latent_frames:] + + if requested_frames is not None: + requested_frames = min(requested_frames, video.shape[0]) + video = video[:requested_frames] + video = rearrange(video, "f c h w -> 1 c f h w") + valid_frames = (video.shape[2] - 1) // 8 * 8 + 1 + video = video[:, :, :valid_frames] + return self._encode_video(video * 2.0 - 1.0, vae_encoder, device) + + # ------------------------------------------------------------------ + # Unified generation pipeline + # ------------------------------------------------------------------ + + def _generate_sample( + self, + sample: ValidationSample, + cached_embeddings: CachedPromptEmbeddings | None, + cached_media: CachedSampleMedia, + transformer: "LTXModel", + device: torch.device, + sampling_ctx: SamplingContext, + ) -> tuple[Tensor | None, Tensor | None]: + """Generate one sample: build conditioned states, denoise, decode.""" + dims = sample.video_dims or self._config.video_dims + width, height, num_frames = dims + seed = sample.seed or self._config.seed + generate_audio = self._config.generate_audio + generate_video = self._config.generate_video + + # Determine frozen modalities from conditions + video_frozen = any(c.type == "video_to_audio" for c in sample.conditions) + audio_frozen = any(c.type == "audio_to_video" for c in sample.conditions) + + # 1. Prompt embeddings + v_ctx_pos, a_ctx_pos, v_ctx_neg, a_ctx_neg = self._get_prompt_embeddings(cached_embeddings, device) + + # 2. Build video state + generator = torch.Generator(device=device).manual_seed(seed) + noiser = GaussianNoiser(generator=generator) + + video_state: LatentState | None = None + video_clean: LatentState | None = None + video_tools: VideoLatentTools | None = None + + if generate_video or video_frozen: + video_tools = self._create_video_tools(width, height, num_frames) + + if video_frozen: + frozen_media = self._find_condition_media(sample, cached_media, "video_to_audio") + video_state = video_tools.create_initial_state( + device=device, dtype=torch.bfloat16, initial_latent=frozen_media.latent.to(device) + ) + video_state = replace(video_state, denoise_mask=torch.zeros_like(video_state.denoise_mask)) + video_clean = video_state + else: + video_state = video_tools.create_initial_state(device=device, dtype=torch.bfloat16) + video_state = self._apply_video_conditionings(video_state, video_tools, sample, cached_media, device) + video_clean = video_state + video_state = noiser(video_state, noise_scale=1.0) + + # 3. Build audio state + audio_state: LatentState | None = None + audio_clean: LatentState | None = None + audio_tools: AudioLatentTools | None = None + + if generate_audio or audio_frozen: + if audio_frozen: + frozen_media = self._find_condition_media(sample, cached_media, "audio_to_video") + frozen_latent = frozen_media.latent.to(device) + audio_tools = AudioLatentTools( + patchifier=self._audio_patchifier, + target_shape=AudioLatentShape.from_torch_shape(frozen_latent.shape), + ) + audio_state = audio_tools.create_initial_state( + device=device, dtype=torch.bfloat16, initial_latent=frozen_latent + ) + audio_state = replace(audio_state, denoise_mask=torch.zeros_like(audio_state.denoise_mask)) + audio_clean = audio_state + else: + audio_tools = self._create_audio_tools(num_frames, self._config.frame_rate) + audio_state = audio_tools.create_initial_state(device=device, dtype=torch.bfloat16) + audio_state = self._apply_audio_conditionings(audio_state, audio_tools, sample, cached_media, device) + audio_clean = audio_state + audio_state = noiser(audio_state, noise_scale=1.0) + + # 4. Denoising loop + video_state, audio_state = self._run_denoising( + transformer=transformer, + video_state=video_state, + audio_state=audio_state, + video_clean=video_clean, + audio_clean=audio_clean, + video_frozen=video_frozen, + audio_frozen=audio_frozen, + 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, + sampling_ctx=sampling_ctx, + ) + + # 5. Decode modalities (both generated and frozen — frozen audio/video is included in output) + video_output = self._finalize_modality(video_state, video_tools, self._decode_video, device) + audio_output = self._finalize_modality(audio_state, audio_tools, self._decode_audio, device) + + # 6. Side-by-side reference output + if video_output is not None: + video_output = self._apply_reference_side_by_side(video_output, sample, cached_media) + + return video_output, audio_output + + # ------------------------------------------------------------------ + # Conditioning application + # ------------------------------------------------------------------ + + @staticmethod + def _apply_video_conditionings( + state: LatentState, + tools: VideoLatentTools, + sample: ValidationSample, + cached_media: CachedSampleMedia, + device: torch.device, + ) -> LatentState: + """Apply all video-targeting conditionings from the sample's conditions list.""" + for cond_idx, cond in enumerate(sample.conditions): + media = cached_media.conditions.get(cond_idx) + if media is None: + continue + + latent = media.latent.to(device=device, dtype=torch.bfloat16) + + if cond.type == "first_frame" or (cond.type == "prefix" and getattr(cond, "video", None) is not None): + state = VideoConditionByLatentIndex(latent=latent, strength=1.0, latent_idx=0).apply_to(state, tools) + + elif cond.type == "suffix" and getattr(cond, "video", None) is not None: + _, _, tgt_frames, _, _ = tools.target_shape.to_torch_shape() + _, _, cond_frames, _, _ = latent.shape + suffix_start = tgt_frames - cond_frames + state = VideoConditionByLatentIndex(latent=latent, strength=1.0, latent_idx=suffix_start).apply_to( + state, tools + ) + + elif cond.type == "reference" and cond.video is not None: + state = VideoConditionByReferenceLatent( + latent=latent, + downscale_factor=cond.downscale_factor, + temporal_scale_factor=cond.temporal_scale_factor, + strength=1.0, + ).apply_to(state, tools) + + elif cond.type == "mask": + mask = media.mask.to(device=device) + state = VideoConditionByMask(latent=latent, mask=mask, strength=1.0).apply_to(state, tools) + + elif cond.type == "spatial_crop": + mask = _build_spatial_crop_mask(cond.spatial_region, tools.target_shape, device) + state = VideoConditionByMask(latent=latent, mask=mask, strength=1.0).apply_to(state, tools) + + return state + + @staticmethod + def _apply_audio_conditionings( + state: LatentState, + tools: AudioLatentTools, + sample: ValidationSample, + cached_media: CachedSampleMedia, + device: torch.device, + ) -> LatentState: + """Apply all audio-targeting conditionings from the sample's conditions list.""" + for cond_idx, cond in enumerate(sample.conditions): + media = cached_media.conditions.get(cond_idx) + if media is None: + continue + + latent = media.latent.to(device=device, dtype=torch.bfloat16) + + if cond.type in ("prefix", "suffix") and getattr(cond, "audio", None) is not None: + tokens = tools.patchifier.patchify(latent) + num_cond_tokens = tokens.shape[1] + + state = state.clone() + start = 0 if cond.type == "prefix" else tools.target_shape.token_count() - num_cond_tokens + stop = start + num_cond_tokens + + state.clean_latent[:, start:stop] = tokens + state.denoise_mask[:, start:stop] = 0.0 + + elif cond.type == "reference" and getattr(cond, "audio", None) is not None: + tokens = tools.patchifier.patchify(latent) + ref_shape = AudioLatentShape.from_torch_shape(latent.shape) + + positions = tools.patchifier.get_patch_grid_bounds( + output_shape=ref_shape, + device=device, + ) + + denoise_mask = torch.zeros( + *tokens.shape[:2], + 1, + device=device, + dtype=torch.float32, + ) + + state = LatentState( + latent=torch.cat([state.latent, torch.zeros_like(tokens, dtype=state.latent.dtype)], dim=1), + denoise_mask=torch.cat([state.denoise_mask, denoise_mask], dim=1), + positions=torch.cat([state.positions, positions], dim=2), + clean_latent=torch.cat([state.clean_latent, tokens], dim=1), + attention_mask=None, + ) + + elif cond.type == "mask" and getattr(cond, "audio", None) is not None: + tokens = tools.patchifier.patchify(latent) + target_len = state.latent.shape[1] + tokens = tokens[:, :target_len] + + mask = media.mask.to(device=device) + if mask.shape[-1] != target_len: + mask = torch.nn.functional.interpolate( + mask.unsqueeze(0).float(), size=target_len, mode="nearest" + ).squeeze(0) + mask = (mask > 0.5).float() + if mask.dim() == 2: + mask = mask.unsqueeze(-1) + + m = mask.to(dtype=state.latent.dtype) + inv = 1 - m + + state = LatentState( + latent=state.latent, + denoise_mask=state.denoise_mask * inv, + positions=state.positions, + clean_latent=state.clean_latent * inv + tokens * m, + attention_mask=state.attention_mask, + ) + + return state + + @staticmethod + def _find_condition_media( + sample: ValidationSample, cached_media: CachedSampleMedia, condition_type: str + ) -> CachedConditionMedia: + """Look up the cached media for the first condition matching the given type.""" + for cond_idx, cond in enumerate(sample.conditions): + if cond.type == condition_type and cond_idx in cached_media.conditions: + return cached_media.conditions[cond_idx] + raise ValueError(f"No cached media found for condition type '{condition_type}'") + + @staticmethod + def _apply_reference_side_by_side( + video_output: Tensor, sample: ValidationSample, cached_media: CachedSampleMedia + ) -> Tensor: + """Concatenate reference video pixels side-by-side with generated output if requested.""" + for cond_idx, cond in enumerate(sample.conditions): + if cond.type == "reference" and cond.include_in_output: + media = cached_media.conditions.get(cond_idx) + if media is not None and media.pixels is not None: + ref_pixels = media.pixels + output_frames = video_output.shape[1] + ref_frames = ref_pixels.shape[1] + if ref_frames < output_frames: + temporal_sf = cond.temporal_scale_factor + if temporal_sf > 1: + # VAE-aligned stretch mirroring training-time subsampling: + # first frame preserved once (aligns with VAE's causal + # first latent), each subsequent ref frame held for + # `temporal_sf` output frames. + indices = torch.tensor([0] + [1 + (i - 1) // temporal_sf for i in range(1, output_frames)]) + indices = indices.clamp(max=ref_frames - 1) + else: + indices = torch.linspace(0, ref_frames - 1, output_frames).round().long() + ref_pixels = ref_pixels[:, indices] + video_output = _concatenate_videos_side_by_side(ref_pixels, video_output) + return video_output + + # ------------------------------------------------------------------ + # Denoising loop + # ------------------------------------------------------------------ + + def _run_denoising( # noqa: PLR0913 + self, + transformer: "LTXModel", + video_state: LatentState | None, + audio_state: LatentState | None, + video_clean: LatentState | None, + audio_clean: LatentState | None, + *, + video_frozen: bool, + audio_frozen: bool, + v_ctx_pos: Tensor, + a_ctx_pos: Tensor, + v_ctx_neg: Tensor | None, + a_ctx_neg: Tensor | None, + device: torch.device, + sampling_ctx: SamplingContext, + ) -> tuple[LatentState | None, LatentState | None]: + """Run the Euler denoising loop with CFG/STG, handling frozen modalities.""" + cfg = self._config + scheduler = LTX2Scheduler() + sigmas = scheduler.execute(steps=cfg.inference_steps).to(device).float() + stepper = EulerDiffusionStep() + cfg_guider = CFGGuider(cfg.guidance_scale) + stg_guider = STGGuider(cfg.stg_scale) + + stg_perturbation_config = ( + self._build_stg_perturbation_config( + cfg.stg_blocks, cfg.stg_mode, transformer.num_blocks, device, next(transformer.parameters()).dtype + ) + if stg_guider.enabled() + else None + ) + + x0_model = X0Model(transformer) + + for step_idx, sigma in enumerate(sigmas[:-1]): + v_sigma = torch.zeros_like(sigma) if video_frozen else sigma + a_sigma = torch.zeros_like(sigma) if audio_frozen else sigma + + video = ( + self._modality_from_latent_state(video_state, v_ctx_pos, v_sigma.unsqueeze(0)) + if video_state is not None + else None + ) + audio = ( + self._modality_from_latent_state(audio_state, a_ctx_pos, a_sigma.unsqueeze(0)) + if audio_state is not None + else None + ) + + pos_video, pos_audio = x0_model(video=video, audio=audio, perturbations=None) + denoised_video, denoised_audio = pos_video, pos_audio + + # CFG + if cfg_guider.enabled() and v_ctx_neg is not None: + video_neg = replace(video, context=v_ctx_neg) if video is not None else None + 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) + + if not video_frozen and denoised_video is not None: + denoised_video = denoised_video + cfg_guider.delta(pos_video, neg_video) + if not audio_frozen and denoised_audio is not None: + denoised_audio = denoised_audio + cfg_guider.delta(pos_audio, neg_audio) + + # STG + if stg_perturbation_config is not None: + ptb_video, ptb_audio = x0_model(video=video, audio=audio, perturbations=stg_perturbation_config) + if not video_frozen and denoised_video is not None: + denoised_video = denoised_video + stg_guider.delta(pos_video, ptb_video) + if not audio_frozen and denoised_audio is not None and ptb_audio is not None: + denoised_audio = denoised_audio + stg_guider.delta(pos_audio, ptb_audio) + + # Re-apply conditioning mask + if denoised_video is not None and video_clean is not None: + denoised_video = self._post_process_latent( + denoised_video, + video_state.denoise_mask, + video_clean.clean_latent, + ) + if denoised_audio is not None and audio_clean is not None: + denoised_audio = self._post_process_latent( + denoised_audio, + audio_state.denoise_mask, + audio_clean.clean_latent, + ) + + # Euler step (skip for frozen modalities) + if video_state is not None and not video_frozen: + video_state = replace( + video_state, + latent=stepper.step(video.latent, denoised_video, sigmas, step_idx), + ) + if audio_state is not None and not audio_frozen: + audio_state = replace( + audio_state, + latent=stepper.step(audio.latent, denoised_audio, sigmas, step_idx), + ) + + sampling_ctx.advance_step() + + return video_state, audio_state + + # --- Finalization (decode) --- + + def _finalize_modality( + self, + state: LatentState | None, + tools: VideoLatentTools | AudioLatentTools | None, + decode_fn: Callable[[LatentState, torch.device], Tensor], + device: torch.device, + ) -> Tensor | None: + """Clear conditioning tokens, unpatchify, and decode a modality (generated or frozen).""" + if state is None or tools is None: + return None + state = tools.clear_conditioning(state) + state = tools.unpatchify(state) + return decode_fn(state, device) + + def _decode_video(self, video_state: LatentState, device: torch.device) -> Tensor: + """Decode video latents to pixels using tiled VAE decoding.""" + self._vae_decoder.to(device) + latent = video_state.latent.to(dtype=torch.bfloat16) + + chunks = list(self._vae_decoder.tiled_decode(latent, tiling_config=_DEFAULT_TILING)) + decoded_video = torch.cat(chunks, dim=2) + + 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 via audio VAE + vocoder.""" + 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() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _save_audio(self, audio: Tensor, path: Path) -> None: + """Save an audio waveform tensor to a .wav file using stdlib wave.""" + import wave # noqa: PLC0415 + + if audio.dim() == 1: + audio = audio.unsqueeze(0) + pcm = (audio.clamp(-1.0, 1.0).cpu() * 32767).to(torch.int16) + with wave.open(str(path), "wb") as wf: + wf.setnchannels(pcm.shape[0]) + wf.setsampwidth(2) + wf.setframerate(self._vocoder.output_sampling_rate) + wf.writeframes(pcm.T.contiguous().numpy().tobytes()) + + @staticmethod + def _encode_audio( + audio_path: str, + audio_encoder: torch.nn.Module, + device: torch.device, + max_duration: float | None = None, + *, + from_end: bool = False, + ) -> Tensor: + """Encode an audio file through the audio VAE encoder. Returns latent on CPU.""" + import torchaudio # noqa: PLC0415 + + waveform, sr = torchaudio.load(audio_path) + if max_duration is not None: + num_samples = min(round(max_duration * sr), waveform.shape[-1]) + waveform = waveform[:, -num_samples:] if from_end else waveform[:, :num_samples] + audio = Audio(waveform=waveform.unsqueeze(0), sampling_rate=sr) + audio_encoder.to(device) + latent = ltx_encode_audio(audio, audio_encoder) + audio_encoder.to("cpu") + return latent.cpu() + + @staticmethod + def _load_and_downsample_mask( + mask_path: str | Path, + target_width: int, + target_height: int, + target_num_frames: int, + ) -> Tensor: + """Load a mask image/video and downsample to latent-space dimensions. + Returns a binary float tensor of shape [1, F', H', W'] where F', H', W' are + the latent-space dimensions corresponding to the target video dims. + """ + from ltx_core.types import SpatioTemporalScaleFactors # noqa: PLC0415 + + sf = SpatioTemporalScaleFactors.default() + latent_f = (target_num_frames - 1) // sf.time + 1 + + mask_path = Path(mask_path) + if mask_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp"): + img = to_tensor(open_image_as_srgb(mask_path)).mean(dim=0, keepdim=True) # [1, H, W] + img = torch.nn.functional.interpolate( + img.unsqueeze(0), size=(target_height, target_width), mode="nearest" + ).squeeze(0) # [1, H, W] + mask_pixels = img.expand(target_num_frames, -1, -1) # [F, H, W] + else: + frames, _ = read_video(str(mask_path), max_frames=target_num_frames) # [F, C, H, W] + frames = frames[:target_num_frames].mean(dim=1) # grayscale [F, H, W] + mask_pixels = torch.nn.functional.interpolate( + frames.unsqueeze(1), size=(target_height, target_width), mode="nearest" + ).squeeze(1) # [F, H, W] + + mask_latent = torch.nn.functional.avg_pool2d( + mask_pixels.unsqueeze(1), kernel_size=(sf.height, sf.width) + ).squeeze(1) # [F, H', W'] + + # Temporal: max-pool over groups of sf.time frames (conservative — any masked frame masks the group) + f_spatial = mask_latent.shape[0] + pad_f = (sf.time - f_spatial % sf.time) % sf.time + 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, sf.time, h_prime, w_prime).amax(dim=1)[:latent_f] + + mask_latent = (mask_latent > 0.5).float() + return mask_latent.unsqueeze(0) # [1, F', H', W'] — unpatchified latent space + + @staticmethod + def _load_audio_mask( + mask_path: str | Path, + target_num_frames: int, + frame_rate: float, + ) -> Tensor: + """Load an audio mask (.pt or .wav) and resample to target audio latent length. + Returns a float tensor of shape [1, num_tokens] in patchified token space. + """ + mask_path = Path(mask_path) + if mask_path.suffix == ".pt": + raw = torch.load(mask_path, map_location="cpu", weights_only=True) + if isinstance(raw, dict): + raw = raw.get("mask", next(iter(raw.values()))) + raw = raw.float().flatten() + else: + import torchaudio # noqa: PLC0415 + + waveform, _sr = torchaudio.load(mask_path) + raw = waveform.abs().mean(dim=0) + + target_duration = target_num_frames / frame_rate + target_shape = AudioLatentShape.from_duration(batch=1, duration=target_duration) + target_len = target_shape.frames + + resampled = torch.nn.functional.interpolate( + raw.unsqueeze(0).unsqueeze(0), size=target_len, mode="nearest" + ).squeeze() + mask = (resampled > 0.5).float() + return mask.unsqueeze(0) # [1, num_tokens] + + def _create_video_tools(self, width: int, height: int, num_frames: int) -> VideoLatentTools: + """Create VideoLatentTools for the given output dimensions.""" + pixel_shape = VideoPixelShape( + batch=1, frames=num_frames, height=height, width=width, fps=self._config.frame_rate + ) + return VideoLatentTools( + patchifier=self._video_patchifier, + target_shape=VideoLatentShape.from_pixel_shape(shape=pixel_shape), + fps=self._config.frame_rate, + scale_factors=VIDEO_SCALE_FACTORS, + causal_fix=True, + ) + + def _create_audio_tools(self, num_frames: int, frame_rate: float) -> AudioLatentTools: + """Create AudioLatentTools for the given video duration.""" + return AudioLatentTools( + patchifier=self._audio_patchifier, + target_shape=AudioLatentShape.from_duration(batch=1, duration=num_frames / frame_rate), + ) + + @staticmethod + def _get_prompt_embeddings( + cached: CachedPromptEmbeddings | None, device: torch.device + ) -> tuple[Tensor, Tensor, Tensor | None, Tensor | None]: + """Move cached prompt embeddings to device, returning (v_pos, a_pos, v_neg, a_neg).""" + if cached is None: + raise ValueError("Cached prompt embeddings are required for validation generation") + return ( + cached.video_context_positive.to(device), + cached.audio_context_positive.to(device), + cached.video_context_negative.to(device) if cached.video_context_negative is not None else None, + cached.audio_context_negative.to(device) if cached.audio_context_negative is not None else None, + ) + + @staticmethod + def log_to_wandb(wandb_run: object, sample_paths: list[Path], step: int) -> None: + """Log validation outputs (images, videos, or audio) to Weights & Biases.""" + import wandb # noqa: PLC0415 + + suffix = sample_paths[0].suffix.lower() + if suffix in (".png", ".jpg", ".jpeg", ".heic", ".webp"): + media = [wandb.Image(str(path)) for path in sample_paths] + elif suffix in (".wav", ".mp3", ".flac", ".ogg"): + media = [wandb.Audio(str(path)) for path in sample_paths] + else: + media = [wandb.Video(str(path), format=suffix.lstrip(".")) for path in sample_paths] + wandb_run.log({"validation_samples": media}, step=step) + + @staticmethod + def _post_process_latent(denoised: Tensor, denoise_mask: Tensor, clean_latent: Tensor) -> Tensor: + """Blend denoised output with clean latent according to the denoise mask.""" + return (denoised * denoise_mask + clean_latent.float() * (1 - denoise_mask)).to(denoised.dtype) + + @staticmethod + def _modality_from_latent_state(state: LatentState, context: Tensor, sigma: Tensor) -> Modality: + """Build a Modality object from a LatentState, text context, and sigma.""" + return Modality( + enabled=True, + latent=state.latent, + sigma=sigma, + timesteps=state.denoise_mask * sigma, + positions=state.positions, + context=context, + context_mask=None, + ) + + @staticmethod + def _build_stg_perturbation_config( + stg_blocks: list[int] | None, + stg_mode: Literal["stg_av", "stg_v"], + num_blocks: int, + device: torch.device, + dtype: torch.dtype, + ) -> BatchedPerturbationConfig: + """Build STG perturbation config that skips self-attention in the specified blocks.""" + perturbations: list[Perturbation] = [ + Perturbation(type=PerturbationType.SKIP_VIDEO_SELF_ATTN, blocks=stg_blocks) + ] + if stg_mode == "stg_av": + perturbations.append(Perturbation(type=PerturbationType.SKIP_AUDIO_SELF_ATTN, blocks=stg_blocks)) + return BatchedPerturbationConfig([PerturbationConfig(perturbations=perturbations)], num_blocks, device, dtype) + + @staticmethod + def _load_first_frame(media_path: Path) -> Tensor: + """Load the first frame from an image or video file as [C, H, W] in [0, 1].""" + video_extensions = {".mp4", ".avi", ".mov", ".mkv", ".webm"} + if media_path.suffix.lower() in video_extensions: + frames, _ = read_video(str(media_path), max_frames=1) + return frames[0] + image = open_image_as_srgb(str(media_path)) + return TF.to_tensor(image) + + @staticmethod + def _resize_and_center_crop(tensor: Tensor, target_height: int, target_width: int) -> Tensor: + """Resize [N, C, H, W] tensor to cover target dims (preserving aspect ratio) and center-crop.""" + current_height, current_width = tensor.shape[2:] + if current_height == target_height and current_width == target_width: + return tensor + + 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 + + tensor = TF.resize(tensor, size=[resize_height, resize_width], interpolation=InterpolationMode.BICUBIC) + tensor = tensor.clamp(0, 1) + h_start = (resize_height - target_height) // 2 + w_start = (resize_width - target_width) // 2 + return tensor[:, :, h_start : h_start + target_height, w_start : w_start + target_width] + + @staticmethod + def _encode_image( + image: Tensor, target_height: int, target_width: int, vae_encoder: torch.nn.Module, device: torch.device + ) -> Tensor: + """Encode a conditioning image to latent space. Returns [B, C, 1, H', W'] on CPU.""" + image = ValidationRunner._resize_and_center_crop(image.unsqueeze(0), target_height, target_width) + image = image.unsqueeze(2) # [B, C, 1, H, W] + image = (image * 2.0 - 1.0).to(device=device, dtype=torch.bfloat16) + vae_encoder.to(device) + encoded = vae_encoder(image) + vae_encoder.to("cpu") + return encoded.cpu() + + @staticmethod + def _preprocess_reference( + video: Tensor, + target_height: int, + target_width: int, + downscale_factor: int = 1, + temporal_scale_factor: int = 1, + ) -> tuple[Tensor, Tensor]: + """Preprocess reference video. Returns (preprocessed [-1,1], pixels [0,1]).""" + ref_height = target_height // downscale_factor + ref_width = target_width // downscale_factor + if ref_height % 32 != 0 or ref_width % 32 != 0: + raise ValueError( + f"Scaled reference dimensions ({ref_height}x{ref_width}) must be divisible by 32. " + f"Original: {target_height}x{target_width}, downscale_factor: {downscale_factor}" + ) + + video = ValidationRunner._resize_and_center_crop(video, ref_height, ref_width) + video = rearrange(video, "f c h w -> 1 c f h w") + valid_frames = (video.shape[2] - 1) // 8 * 8 + 1 + video = video[:, :, :valid_frames] + + # VAE-aligned temporal subsampling: keep frame 0, then every Nth frame + if temporal_scale_factor > 1: + indices = [0, *list(range(1, video.shape[2], temporal_scale_factor))] + video = video[:, :, indices] + + pixels = video[0].clone() + preprocessed = video * 2.0 - 1.0 + return preprocessed, pixels + + @staticmethod + def _encode_video(video: Tensor, vae_encoder: torch.nn.Module, device: torch.device) -> Tensor: + """Encode a [B, C, F, H, W] video tensor through the VAE. Returns latent on CPU.""" + vae_encoder.to(device) + latent = vae_encoder.tiled_encode(video.to(dtype=torch.bfloat16), TilingConfig.default()) + vae_encoder.to("cpu") + return latent.cpu() + + +def _build_spatial_crop_mask( + region: tuple[int, int, int, int], target_shape: VideoLatentShape, device: torch.device +) -> Tensor: + """Build a binary mask from a pixel-space spatial region (y1, x1, y2, x2).""" + y1, x1, y2, x2 = region + _, _, frames, height, width = target_shape.to_torch_shape() + + 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) + + spatial_mask = torch.zeros(height, width, dtype=torch.float32, device=device) + spatial_mask[ly1:ly2, lx1:lx2] = 1.0 + + return spatial_mask.unsqueeze(0).unsqueeze(0).expand(1, frames, -1, -1) # [1, F, H, W] + + +def _concatenate_videos_side_by_side(left_video: Tensor, right_video: Tensor) -> Tensor: + """Concatenate two [C, F, H, W] videos horizontally, matching height and padding frames.""" + left_height, left_width = left_video.shape[2], left_video.shape[3] + right_height = right_video.shape[2] + if left_height != right_height: + scale = right_height / left_height + new_width = int(left_width * scale) + c, f, h, w = left_video.shape + left_video = left_video.reshape(c * f, 1, h, w) + left_video = TF.resize(left_video, size=[right_height, new_width], interpolation=InterpolationMode.BICUBIC) + left_video = left_video.clamp(0, 1) + left_video = left_video.reshape(c, f, right_height, new_width) + left_frames, right_frames = left_video.shape[1], right_video.shape[1] + 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) + return torch.cat([left_video, right_video], dim=3) diff --git a/packages/ltx-trainer/src/ltx_trainer/video_utils.py b/packages/ltx-trainer/src/ltx_trainer/video_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..6e5df1bff6b127d016ebd84db2f540e43e9d53f0 --- /dev/null +++ b/packages/ltx-trainer/src/ltx_trainer/video_utils.py @@ -0,0 +1,188 @@ +"""Video I/O utilities using PyAV. +This module provides functions for reading and writing video files using PyAV, +with optional audio support. +""" + +from fractions import Fraction +from pathlib import Path +from typing import Literal + +import av +import numpy as np +import torch +from torch import Tensor + +VideoFormat = Literal["CFHW", "FCHW"] + + +def get_video_frame_count(video_path: str | Path) -> int: + """Get the number of frames in a video file. + Tries three approaches in order: stream metadata, duration*fps estimate, + full decode. The estimate may be off by a few frames for VFR videos or + containers with edit lists — exact for the min_frames filtering use case. + Args: + video_path: Path to the video file + Returns: + Number of frames in the video + """ + with av.open(str(video_path)) as container: + video_stream = container.streams.video[0] + + if video_stream.frames > 0: + return video_stream.frames + + # Fast estimate from container metadata (avoids full decode). + # Uses Fraction arithmetic to prevent float precision loss. + rate = video_stream.average_rate or video_stream.base_rate + if video_stream.duration and video_stream.time_base and rate: + duration = Fraction(video_stream.duration) * Fraction(video_stream.time_base) + return round(duration * Fraction(rate)) + + # Last resort: full decode (very slow for 4K) + return sum(1 for _ in container.decode(video=0)) + + +def read_video(video_path: str | Path, max_frames: int | None = None) -> tuple[Tensor, float]: + """Load frames from a video file using PyAV. + Args: + video_path: Path to the video file + max_frames: Maximum number of frames to read. If None, reads all frames. + Returns: + Video tensor with shape [F, C, H, W] in range [0, 1] and frames per second (fps). + """ + with av.open(str(video_path)) as container: + video_stream = container.streams.video[0] + fps = float(video_stream.average_rate or video_stream.base_rate or 24) + + frames = [] + for frame in container.decode(video=0): + if max_frames is not None and len(frames) >= max_frames: + break + frames.append(frame.to_ndarray(format="rgb24")) + + frames_np = np.stack(frames, axis=0) # [F, H, W, C] + video = torch.from_numpy(frames_np).float().div(255.0) # [F, H, W, C] in [0, 1] + return video.permute(0, 3, 1, 2), fps # [F, C, H, W] + + +def save_video( + video_tensor: torch.Tensor, + output_path: Path | str, + fps: float = 24.0, + audio: torch.Tensor | None = None, + audio_sample_rate: int | None = None, + video_format: VideoFormat | None = None, +) -> None: + """Save a video tensor to a file using PyAV, optionally with audio. + Args: + video_tensor: Video tensor of shape [C, F, H, W] or [F, C, H, W] in range [0, 1] or [0, 255] + output_path: Path to save the video + fps: Frames per second for the output video + audio: Optional audio tensor of shape [C, samples] or [samples, C] in range [-1, 1] + audio_sample_rate: Sample rate for the audio (required if audio is provided) + video_format: Explicit layout of ``video_tensor``, either ``"CFHW"`` or ``"FCHW"``. + When ``None`` (default), the layout is auto-detected using a heuristic that only + works when ``shape[1] > 3`` — the ambiguous ``[C=3, F=3, H, W]`` / ``[F=3, C=3, H, W]`` + case requires passing this argument explicitly. + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Normalize to [F, H, W, C] uint8 numpy array + video_np = _prepare_video_array(video_tensor, video_format=video_format) + _, height, width, _ = video_np.shape + + with av.open(str(output_path), mode="w") as container: + # Setup video stream + video_stream = container.add_stream("libx264", rate=int(fps)) + video_stream.width = width + video_stream.height = height + video_stream.pix_fmt = "yuv420p" + video_stream.options = {"crf": "18"} + + # Setup audio stream if needed + if audio is not None: + if audio_sample_rate is None: + raise ValueError("audio_sample_rate must be provided when audio is given") + audio_stream = container.add_stream("aac", rate=audio_sample_rate) + audio_stream.layout = "stereo" + audio_stream.time_base = Fraction(1, audio_sample_rate) + + # Write video frames + for frame_array in video_np: + frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24") + for packet in video_stream.encode(frame): + container.mux(packet) + for packet in video_stream.encode(): + container.mux(packet) + + # Write audio if provided + if audio is not None: + _write_audio(container, audio_stream, audio, audio_sample_rate) + + +def _prepare_video_array( + video_tensor: torch.Tensor, + video_format: VideoFormat | None = None, +) -> np.ndarray: + """Convert video tensor to [F, H, W, C] uint8 numpy array. + If ``video_format`` is provided, it is trusted. Otherwise, the layout is auto-detected + using a heuristic that only fires when ``shape[0] == 3 and shape[1] > 3`` (CFHW). The + ambiguous ``[C=3, F=3, H, W]`` / ``[F=3, C=3, H, W]`` case cannot be disambiguated and + defaults to the FCHW interpretation — callers must pass ``video_format`` explicitly for + 3-frame CFHW tensors. + """ + if video_format == "CFHW": + video_tensor = video_tensor.permute(1, 0, 2, 3) # [C, F, H, W] -> [F, C, H, W] + elif video_format is None and video_tensor.shape[0] == 3 and video_tensor.shape[1] > 3: + video_tensor = video_tensor.permute(1, 0, 2, 3) + + # Normalize to [0, 255] uint8 + if video_tensor.max() <= 1.0: + video_tensor = video_tensor * 255 + + # [F, C, H, W] -> [F, H, W, C] + return video_tensor.permute(0, 2, 3, 1).to(torch.uint8).cpu().numpy() + + +def _write_audio( + container: av.container.Container, + audio_stream: av.audio.AudioStream, + audio: torch.Tensor, + sample_rate: int, +) -> None: + """Write audio tensor to container as stereo AAC.""" + audio = audio.cpu().float() + + # Normalize to [samples, 2] stereo format + if audio.ndim == 1: + audio = audio.unsqueeze(1).repeat(1, 2) # Mono -> stereo + elif audio.shape[0] == 2 and audio.shape[1] != 2: + audio = audio.T # [2, samples] -> [samples, 2] + if audio.shape[1] == 1: + audio = audio.repeat(1, 2) # Mono -> stereo + + # Convert to int16 interleaved: [samples, 2] -> [1, samples*2] + audio_int16 = (audio.clamp(-1, 1) * 32767).to(torch.int16) + audio_interleaved = audio_int16.contiguous().view(1, -1).numpy() + + # Create audio frame + frame = av.AudioFrame.from_ndarray(audio_interleaved, format="s16", layout="stereo") + frame.sample_rate = sample_rate + + # Resample to encoder format and write + resampler = av.audio.resampler.AudioResampler( + format=audio_stream.codec_context.format, + layout=audio_stream.codec_context.layout, + rate=sample_rate, + ) + + pts = 0 + for resampled_frame in resampler.resample(frame): + resampled_frame.pts = pts + pts += resampled_frame.samples + for packet in audio_stream.encode(resampled_frame): + container.mux(packet) + + for packet in audio_stream.encode(): + container.mux(packet) diff --git a/packages/ltx-trainer/templates/model_card.md b/packages/ltx-trainer/templates/model_card.md new file mode 100644 index 0000000000000000000000000000000000000000..accb8c448062489d2a21a55f1a743c86e14d0420 --- /dev/null +++ b/packages/ltx-trainer/templates/model_card.md @@ -0,0 +1,59 @@ +--- +tags: + - ltx-2 + - ltx-video + - text-to-video + - audio-video +pinned: true +language: + - en +license: other +pipeline_tag: text-to-video +library_name: diffusers +--- + +# {model_name} + +This is a fine-tuned version of [`{base_model}`]({base_model_link}) trained on custom data. + +## Model Details + +- **Base Model:** [`{base_model}`]({base_model_link}) +- **Training Type:** {training_type} +- **Training Steps:** {training_steps} +- **Learning Rate:** {learning_rate} +- **Batch Size:** {batch_size} + +## Sample Outputs + +| | | | | +|:---:|:---:|:---:|:---:| +{sample_grid} + +## Usage + +This model is designed to be used with the LTX-2 (Lightricks Audio-Video) pipeline. + +### 🔌 Using Trained LoRAs in ComfyUI + +In order to use the trained LoRA in ComfyUI, follow these steps: + +1. Copy your trained LoRA checkpoint (`.safetensors` file) to the `models/loras` folder in your ComfyUI installation. +2. In your ComfyUI workflow: + - Add the "Load LoRA" node to choose your LoRA file + - Connect it to the "Load Checkpoint" node to apply the LoRA to the base model + +You can find reference Text-to-Video (T2V) and Image-to-Video (I2V) workflows in the +official [LTX-2 repository](https://github.com/Lightricks/LTX-2). + +### Example Prompts + +{validation_prompts} + + +This model inherits the license of the base model ([`{base_model}`]({base_model_link})). + +## Acknowledgments + +- Base model: [Lightricks](https://huggingface.co/Lightricks/LTX-2) +- Trainer: [LTX-2](https://github.com/Lightricks/LTX-2)