Automated PR - 2026-01-05
This commit is contained in:
+486
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Auto-caption videos with audio using multimodal models.
|
||||
This script provides a command-line interface for generating captions for videos
|
||||
(including audio) using multimodal models. It supports:
|
||||
- Qwen2.5-Omni: Local model for audio-visual captioning (default)
|
||||
- Gemini Flash: Cloud-based API for audio-visual captioning
|
||||
The paths to videos in the generated dataset/captions file will be RELATIVE to the
|
||||
directory where the output file is stored. This makes the dataset more portable and
|
||||
easier to use in different environments.
|
||||
Basic usage:
|
||||
# Caption a single video (includes audio by default)
|
||||
caption_videos.py video.mp4 --output captions.json
|
||||
# Caption all videos in a directory
|
||||
caption_videos.py videos_dir/ --output captions.csv
|
||||
# Caption with custom instruction
|
||||
caption_videos.py video.mp4 --instruction "Describe what happens in this video in detail."
|
||||
Advanced usage:
|
||||
# Use Gemini Flash API (requires GEMINI_API_KEY or GOOGLE_API_KEY env var)
|
||||
caption_videos.py videos_dir/ --captioner-type gemini_flash
|
||||
# Disable audio processing (video-only captions)
|
||||
caption_videos.py videos_dir/ --no-audio
|
||||
# Process videos with specific extensions and save as JSON
|
||||
caption_videos.py videos_dir/ --extensions mp4,mov,avi --output captions.json
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
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
|
||||
|
||||
from ltx_trainer.captioning import 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.",
|
||||
)
|
||||
|
||||
disable_progress_bar()
|
||||
|
||||
|
||||
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,
|
||||
include_audio: bool,
|
||||
clean_caption: bool,
|
||||
output_format: OutputFormat,
|
||||
override: bool,
|
||||
) -> 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)
|
||||
include_audio: Whether to include audio in captioning
|
||||
clean_caption: Whether to clean up captions
|
||||
output_format: Format to save the captions in
|
||||
override: Whether to override existing captions
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
# Process media files
|
||||
captions = existing_captions.copy()
|
||||
successfully_captioned = 0
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
TextColumn("•"),
|
||||
TimeRemainingColumn(),
|
||||
console=console,
|
||||
)
|
||||
|
||||
with progress:
|
||||
task = progress.add_task("Captioning", total=len(media_to_process))
|
||||
|
||||
for i, media_file in enumerate(media_to_process):
|
||||
progress.update(task, description=f"Captioning [bold blue]{media_file.name}[/]")
|
||||
|
||||
try:
|
||||
# Generate caption for the media
|
||||
caption = captioner.caption(
|
||||
path=media_file,
|
||||
fps=fps,
|
||||
include_audio=include_audio,
|
||||
clean_caption=clean_caption,
|
||||
)
|
||||
|
||||
# Convert absolute path to relative path (relative to the output file's directory)
|
||||
rel_path = str(media_file.resolve().relative_to(base_dir))
|
||||
# Store the caption with the relative path as key
|
||||
captions[rel_path] = caption
|
||||
successfully_captioned += 1
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Error captioning {media_file}: {e}[/]")
|
||||
|
||||
if i % SAVE_INTERVAL == 0:
|
||||
_save_captions(captions, output_path, output_format)
|
||||
|
||||
# Advance progress bar
|
||||
progress.advance(task)
|
||||
|
||||
# Save captions to file
|
||||
_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,
|
||||
),
|
||||
device: str | None = typer.Option(
|
||||
None,
|
||||
"--device",
|
||||
"-d",
|
||||
help="Device to use for inference (e.g., 'cuda', 'cuda:0', 'cpu'). Only for local models.",
|
||||
),
|
||||
use_8bit: bool = typer.Option(
|
||||
False,
|
||||
"--use-8bit",
|
||||
help="Whether to use 8-bit precision for the captioning model (reduces memory usage)",
|
||||
),
|
||||
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(
|
||||
3,
|
||||
"--fps",
|
||||
"-f",
|
||||
help="Frames per second to sample from videos (ignored for images)",
|
||||
),
|
||||
include_audio: bool = typer.Option(
|
||||
True,
|
||||
"--audio/--no-audio",
|
||||
help="Whether to include audio in captioning (for videos with audio tracks)",
|
||||
),
|
||||
clean_caption: bool = typer.Option(
|
||||
True,
|
||||
"--clean-caption/--raw-caption",
|
||||
help="Whether to clean up captions by removing common VLM patterns",
|
||||
),
|
||||
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)",
|
||||
),
|
||||
) -> None:
|
||||
"""Auto-caption videos with audio using multimodal models.
|
||||
This script supports audio-visual captioning using:
|
||||
- Qwen2.5-Omni: Local model (default) - processes both video and audio
|
||||
- Gemini Flash: Cloud API - requires GOOGLE_API_KEY environment variable
|
||||
The paths in the output file will be relative to the output file's directory.
|
||||
Examples:
|
||||
# Caption videos with audio using Qwen2.5-Omni (default)
|
||||
caption_videos.py videos_dir/ -o captions.json
|
||||
# Caption using Gemini Flash API
|
||||
caption_videos.py videos_dir/ -o captions.json -c gemini_flash
|
||||
# Caption without audio (video-only)
|
||||
caption_videos.py videos_dir/ -o captions.json --no-audio
|
||||
# Caption with custom instruction
|
||||
caption_videos.py video.mp4 -o captions.json -i "Describe this video in detail"
|
||||
"""
|
||||
|
||||
# Determine device for local models
|
||||
device_str = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
# Parse extensions
|
||||
ext_list = [ext.strip() for ext in extensions.split(",")]
|
||||
|
||||
# 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}[/]")
|
||||
|
||||
# Initialize captioning model
|
||||
with console.status("Loading captioning model...", spinner="dots"):
|
||||
if captioner_type == CaptionerType.QWEN_OMNI:
|
||||
captioner = create_captioner(
|
||||
captioner_type=captioner_type,
|
||||
device=device_str,
|
||||
use_8bit=use_8bit,
|
||||
instruction=instruction,
|
||||
)
|
||||
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} captioning model loaded successfully")
|
||||
|
||||
# Caption media files
|
||||
caption_media(
|
||||
input_path=input_path,
|
||||
output_path=output,
|
||||
captioner=captioner,
|
||||
extensions=ext_list,
|
||||
recursive=recursive,
|
||||
fps=fps,
|
||||
include_audio=include_audio,
|
||||
clean_caption=clean_caption,
|
||||
output_format=output_format,
|
||||
override=override,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Compute reference videos for IC-LoRA training.
|
||||
This script provides a command-line interface for generating reference videos to be used for IC-LoRA training.
|
||||
Note that it reads and writes to the same file (the output of caption_videos.py),
|
||||
where it adds the "reference_path" field to the JSON.
|
||||
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 Dict
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
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,
|
||||
) -> Dict[str, str]:
|
||||
"""Get set of existing reference video paths without loading the actual files.
|
||||
Args:
|
||||
output_path: Path to the reference video paths file
|
||||
Returns:
|
||||
Dictionary mapping media paths to reference video paths
|
||||
"""
|
||||
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 _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 = item["media_path"]
|
||||
reference_path = reference_paths[media_path]
|
||||
new_json_data[i]["reference_path"] = reference_path
|
||||
|
||||
with output_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(new_json_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
console.print(f"[bold green]✓[/] Reference video paths saved to [cyan]{output_path}[/]")
|
||||
console.print("[bold yellow]Note:[/] Use these files with ImageOrVideoDataset by setting:")
|
||||
console.print(" reference_column='[cyan]reference_path[/]'")
|
||||
console.print(" video_column='[cyan]media_path[/]'")
|
||||
|
||||
|
||||
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(sample["media_path"]) 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 = [item["media_path"] for item in meta_data]
|
||||
reference_paths = {rel_path: str(media_path_to_reference_path(Path(rel_path))) for rel_path in media_paths}
|
||||
|
||||
with progress:
|
||||
task = progress.add_task("Computing condition on videos", total=len(media_to_process))
|
||||
|
||||
for media_file in media_to_process:
|
||||
progress.update(task, description=f"Processing [bold blue]{media_file.name}[/]")
|
||||
|
||||
rel_path = str(media_file.resolve().relative_to(base_dir))
|
||||
reference_path = media_path_to_reference_path(media_file)
|
||||
reference_paths[rel_path] = str(reference_path.relative_to(base_dir))
|
||||
|
||||
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 | None = typer.Option( # noqa: B008
|
||||
None,
|
||||
"--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()
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
#!/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 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_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
|
||||
|
||||
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._load_model(model_path, vae_tiling, with_audio)
|
||||
|
||||
def _load_model(self, model_path: str, vae_tiling: bool, 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 vae_tiling:
|
||||
self.vae.enable_tiling()
|
||||
|
||||
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}")
|
||||
|
||||
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]
|
||||
_seq_len, channels = latents.shape
|
||||
latents = latents.reshape(num_frames, height, width, channels)
|
||||
latents = latents.permute(3, 0, 1, 2) # [F, H, W, C] -> [C, F, H, W]
|
||||
|
||||
# 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 (VAE decoder uses forward/call, not decode method)
|
||||
video = self.vae(latents) # [B, C, F, H, W]
|
||||
|
||||
# Convert to [F, C, H, W] format and normalize to [0, 1]
|
||||
video = video[0] # Remove batch dimension -> [C, F, H, W]
|
||||
video = video.permute(1, 0, 2, 3) # [C, F, H, W] -> [F, C, H, W]
|
||||
video = (video + 1) / 2 # Denormalize from [-1, 1] to [0, 1]
|
||||
video = video.clamp(0, 1)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
@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_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 = latents.reshape(num_time_steps, freq_bins, -1) # [T, F, C]
|
||||
latents = latents.permute(2, 0, 1) # [T, F, C] -> [C, T, F]
|
||||
|
||||
# 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_sample_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=False,
|
||||
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()
|
||||
Executable
+443
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
# ruff: noqa: T201
|
||||
"""
|
||||
CLI script for running LTX video/audio generation inference.
|
||||
Usage:
|
||||
# Text-to-Video + Audio (default behavior)
|
||||
python scripts/inference.py --checkpoint path/to/model.safetensors \
|
||||
--text-encoder-path path/to/gemma \
|
||||
--prompt "A cat playing with a ball" --output output.mp4
|
||||
# Video only (skip audio)
|
||||
python scripts/inference.py --checkpoint path/to/model.safetensors \
|
||||
--text-encoder-path path/to/gemma \
|
||||
--prompt "A cat playing with a ball" --skip-audio --output output.mp4
|
||||
# Image-to-Video
|
||||
python scripts/inference.py --checkpoint path/to/model.safetensors \
|
||||
--text-encoder-path path/to/gemma \
|
||||
--prompt "A cat walking" --condition-image first_frame.png --output output.mp4
|
||||
# Video-to-Video (IC-LoRA style)
|
||||
python scripts/inference.py --checkpoint path/to/model.safetensors \
|
||||
--text-encoder-path path/to/gemma \
|
||||
--prompt "A cat turning into a dog" --reference-video input.mp4 --output output.mp4
|
||||
# With LoRA weights
|
||||
python scripts/inference.py --checkpoint path/to/model.safetensors \
|
||||
--text-encoder-path path/to/gemma \
|
||||
--lora-path path/to/lora.safetensors \
|
||||
--prompt "A cat in my custom style" --output output.mp4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import torchaudio
|
||||
from peft import LoraConfig, get_peft_model, set_peft_model_state_dict
|
||||
from safetensors.torch import load_file
|
||||
from torchvision import transforms
|
||||
|
||||
from ltx_trainer.model_loader import load_model
|
||||
from ltx_trainer.progress import StandaloneSamplingProgress
|
||||
from ltx_trainer.utils import open_image_as_srgb
|
||||
from ltx_trainer.validation_sampler import GenerationConfig, ValidationSampler
|
||||
from ltx_trainer.video_utils import read_video, save_video
|
||||
|
||||
|
||||
def load_image(image_path: str) -> torch.Tensor:
|
||||
"""Load an image and convert to tensor [C, H, W] in [0, 1]."""
|
||||
image = open_image_as_srgb(image_path)
|
||||
transform = transforms.ToTensor()
|
||||
return transform(image)
|
||||
|
||||
|
||||
def extract_lora_target_modules(state_dict: dict[str, torch.Tensor]) -> list[str]:
|
||||
"""Extract target module names from LoRA checkpoint keys.
|
||||
LoRA keys follow the pattern (after removing "diffusion_model." prefix):
|
||||
- transformer_blocks.0.attn1.to_k.lora_A.weight
|
||||
- transformer_blocks.0.ff.net.0.proj.lora_B.weight
|
||||
This extracts the full module path like "transformer_blocks.0.attn1.to_k".
|
||||
Using full paths is more robust than partial patterns.
|
||||
"""
|
||||
target_modules = set()
|
||||
# Pattern to extract everything before .lora_A or .lora_B
|
||||
pattern = re.compile(r"(.+)\.lora_[AB]\.")
|
||||
|
||||
for key in state_dict:
|
||||
match = pattern.match(key)
|
||||
if match:
|
||||
module_path = match.group(1)
|
||||
target_modules.add(module_path)
|
||||
|
||||
return sorted(target_modules)
|
||||
|
||||
|
||||
def load_lora_weights(transformer: torch.nn.Module, lora_path: str | Path) -> torch.nn.Module:
|
||||
"""Load LoRA weights into the transformer model.
|
||||
The LoRA rank and target modules are automatically detected from the checkpoint.
|
||||
Alpha is set equal to rank (standard practice for inference).
|
||||
Args:
|
||||
transformer: The base transformer model
|
||||
lora_path: Path to the LoRA weights (.safetensors)
|
||||
Returns:
|
||||
The transformer model with LoRA weights applied
|
||||
"""
|
||||
print(f"Loading LoRA weights from {lora_path}...")
|
||||
|
||||
# Load the LoRA state dict
|
||||
state_dict = load_file(str(lora_path))
|
||||
|
||||
# Remove "diffusion_model." prefix (ComfyUI-compatible format)
|
||||
state_dict = {k.replace("diffusion_model.", "", 1): v for k, v in state_dict.items()}
|
||||
|
||||
# Extract target modules from the checkpoint
|
||||
target_modules = extract_lora_target_modules(state_dict)
|
||||
if not target_modules:
|
||||
raise ValueError(f"Could not extract target modules from LoRA checkpoint: {lora_path}")
|
||||
print(f" Detected {len(target_modules)} target modules")
|
||||
|
||||
# Auto-detect rank from the first lora_A weight shape
|
||||
lora_rank = None
|
||||
for key, value in state_dict.items():
|
||||
if "lora_A" in key and value.ndim == 2:
|
||||
lora_rank = value.shape[0]
|
||||
break
|
||||
if lora_rank is None:
|
||||
raise ValueError("Could not auto-detect LoRA rank from weights")
|
||||
print(f" LoRA rank: {lora_rank}")
|
||||
|
||||
# Create LoRA config and wrap the model
|
||||
# Alpha = rank is standard for inference (maintains the trained scale)
|
||||
lora_config = LoraConfig(
|
||||
r=lora_rank,
|
||||
lora_alpha=lora_rank,
|
||||
target_modules=target_modules,
|
||||
lora_dropout=0.0,
|
||||
init_lora_weights=True,
|
||||
)
|
||||
|
||||
# Wrap the transformer with PEFT to add LoRA layers
|
||||
transformer = get_peft_model(transformer, lora_config)
|
||||
|
||||
# Load the LoRA weights
|
||||
base_model = transformer.get_base_model()
|
||||
set_peft_model_state_dict(base_model, state_dict)
|
||||
|
||||
print("✓ LoRA weights loaded successfully")
|
||||
return transformer
|
||||
|
||||
|
||||
def main() -> None: # noqa: PLR0912, PLR0915
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LTX Video/Audio Generation",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
|
||||
# Model arguments
|
||||
parser.add_argument(
|
||||
"--checkpoint",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to model checkpoint (.safetensors)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text-encoder-path",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to Gemma text encoder directory",
|
||||
)
|
||||
|
||||
# LoRA arguments
|
||||
parser.add_argument(
|
||||
"--lora-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to LoRA weights (.safetensors)",
|
||||
)
|
||||
|
||||
# Generation arguments
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Text prompt for generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--negative-prompt",
|
||||
type=str,
|
||||
default="",
|
||||
help="Negative prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=544,
|
||||
help="Video height (must be divisible by 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=960,
|
||||
help="Video width (must be divisible by 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-frames",
|
||||
type=int,
|
||||
default=97,
|
||||
help="Number of video frames (must be k*8 + 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-rate",
|
||||
type=float,
|
||||
default=25.0,
|
||||
help="Video frame rate",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Number of denoising steps",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--guidance-scale",
|
||||
type=float,
|
||||
default=4.0,
|
||||
help="Classifier-free guidance scale (CFG)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stg-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. Default: 1.0",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stg-blocks",
|
||||
type=int,
|
||||
nargs="*",
|
||||
default=[29],
|
||||
help="Which transformer blocks to perturb for STG. Default: 29 (single block).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stg-mode",
|
||||
type=str,
|
||||
default="stg_av",
|
||||
choices=["stg_av", "stg_v"],
|
||||
help="STG mode: 'stg_av' perturbs both audio and video, 'stg_v' perturbs video only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="Random seed for reproducibility",
|
||||
)
|
||||
|
||||
# Conditioning arguments
|
||||
parser.add_argument(
|
||||
"--condition-image",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to conditioning image for image-to-video generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-video",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to reference video for video-to-video generation (IC-LoRA style)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-reference-in-output",
|
||||
action="store_true",
|
||||
help="Include reference video side-by-side with generated output (only for V2V)",
|
||||
)
|
||||
|
||||
# Audio arguments
|
||||
parser.add_argument(
|
||||
"--skip-audio",
|
||||
action="store_true",
|
||||
help="Skip audio generation (by default, audio is generated alongside video)",
|
||||
)
|
||||
|
||||
# Output arguments
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Output video path (.mp4)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output audio path (.wav, optional - if not provided, audio will be embedded in video)",
|
||||
)
|
||||
|
||||
# Device arguments
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default="cuda",
|
||||
help="Device to run on (cuda/cpu)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate conditioning arguments
|
||||
if args.include_reference_in_output and args.reference_video is None:
|
||||
parser.error("--include-reference-in-output requires --reference-video")
|
||||
|
||||
# Validate arguments
|
||||
generate_audio = not args.skip_audio
|
||||
|
||||
print("=" * 80)
|
||||
print("LTX Video/Audio Generation")
|
||||
print("=" * 80)
|
||||
|
||||
# Determine if we need VAE encoder (for image or video conditioning)
|
||||
need_vae_encoder = args.condition_image is not None or args.reference_video is not None
|
||||
|
||||
components = load_model(
|
||||
checkpoint_path=args.checkpoint,
|
||||
device="cpu", # Load to CPU first, sampler will move to device as needed
|
||||
dtype=torch.bfloat16,
|
||||
with_video_vae_encoder=need_vae_encoder,
|
||||
with_video_vae_decoder=True,
|
||||
with_audio_vae_decoder=generate_audio,
|
||||
with_vocoder=generate_audio,
|
||||
with_text_encoder=True,
|
||||
text_encoder_path=args.text_encoder_path,
|
||||
)
|
||||
|
||||
# Apply LoRA weights if provided
|
||||
transformer = components.transformer
|
||||
if args.lora_path is not None:
|
||||
transformer = load_lora_weights(transformer, args.lora_path)
|
||||
|
||||
# Load conditioning image if provided
|
||||
condition_image = None
|
||||
if args.condition_image:
|
||||
print(f"Loading conditioning image from {args.condition_image}...")
|
||||
condition_image = load_image(args.condition_image)
|
||||
|
||||
# Load reference video if provided
|
||||
reference_video = None
|
||||
if args.reference_video:
|
||||
print(f"Loading reference video from {args.reference_video}...")
|
||||
reference_video, ref_fps = read_video(args.reference_video, max_frames=args.num_frames)
|
||||
print(f" Loaded {reference_video.shape[0]} frames @ {ref_fps:.1f} fps")
|
||||
|
||||
# Determine generation mode
|
||||
if args.reference_video is not None and args.condition_image is not None:
|
||||
mode = "Video-to-Video + Image Conditioning (V2V+I2V)"
|
||||
elif args.reference_video is not None:
|
||||
mode = "Video-to-Video (V2V)"
|
||||
elif args.condition_image is not None:
|
||||
mode = "Image-to-Video (I2V)"
|
||||
else:
|
||||
mode = "Text-to-Video (T2V)"
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Generation Parameters")
|
||||
print("=" * 80)
|
||||
print(f"Mode: {mode}")
|
||||
print(f"Prompt: {args.prompt}")
|
||||
if args.negative_prompt:
|
||||
print(f"Negative prompt: {args.negative_prompt}")
|
||||
print(f"Resolution: {args.width}x{args.height}")
|
||||
print(f"Frames: {args.num_frames} @ {args.frame_rate} fps")
|
||||
print(f"Inference steps: {args.num_inference_steps}")
|
||||
print(f"CFG scale: {args.guidance_scale}")
|
||||
if args.stg_scale > 0:
|
||||
blocks_str = args.stg_blocks if args.stg_blocks else "all"
|
||||
print(f"STG scale: {args.stg_scale} (mode: {args.stg_mode}, blocks: {blocks_str})")
|
||||
else:
|
||||
print("STG: disabled")
|
||||
print(f"Seed: {args.seed}")
|
||||
if args.lora_path:
|
||||
print(f"LoRA: {args.lora_path}")
|
||||
if condition_image is not None:
|
||||
print(f"Conditioning: Image ({args.condition_image})")
|
||||
if reference_video is not None:
|
||||
print(f"Reference: Video ({args.reference_video})")
|
||||
if args.include_reference_in_output:
|
||||
print(" → Will include reference side-by-side in output")
|
||||
if generate_audio:
|
||||
video_duration = args.num_frames / args.frame_rate
|
||||
print(f"Audio: Enabled (duration will match video: {video_duration:.2f}s)")
|
||||
print("=" * 80)
|
||||
|
||||
print(f"\nGenerating {'video + audio' if generate_audio else 'video'}...")
|
||||
|
||||
# Create generation config
|
||||
gen_config = GenerationConfig(
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
frame_rate=args.frame_rate,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
guidance_scale=args.guidance_scale,
|
||||
seed=args.seed,
|
||||
condition_image=condition_image,
|
||||
reference_video=reference_video,
|
||||
generate_audio=generate_audio,
|
||||
include_reference_in_output=args.include_reference_in_output,
|
||||
stg_scale=args.stg_scale,
|
||||
stg_blocks=args.stg_blocks,
|
||||
stg_mode=args.stg_mode,
|
||||
)
|
||||
|
||||
# Generate with progress bar
|
||||
with StandaloneSamplingProgress(num_steps=args.num_inference_steps) as progress:
|
||||
# Create sampler with progress context
|
||||
sampler = ValidationSampler(
|
||||
transformer=transformer,
|
||||
vae_decoder=components.video_vae_decoder,
|
||||
vae_encoder=components.video_vae_encoder,
|
||||
text_encoder=components.text_encoder,
|
||||
audio_decoder=components.audio_vae_decoder if generate_audio else None,
|
||||
vocoder=components.vocoder if generate_audio else None,
|
||||
sampling_context=progress,
|
||||
)
|
||||
video, audio = sampler.generate(
|
||||
config=gen_config,
|
||||
device=args.device,
|
||||
)
|
||||
|
||||
# Save video
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get audio sample rate from vocoder if audio was generated
|
||||
audio_sample_rate = None
|
||||
if audio is not None and components.vocoder is not None:
|
||||
audio_sample_rate = components.vocoder.output_sample_rate
|
||||
|
||||
save_video(
|
||||
video_tensor=video,
|
||||
output_path=output_path,
|
||||
fps=args.frame_rate,
|
||||
audio=audio,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
)
|
||||
print(f"✓ Video saved to {args.output}")
|
||||
|
||||
# Save separate audio file if requested
|
||||
if audio is not None and args.audio_output is not None:
|
||||
audio_output_path = Path(args.audio_output)
|
||||
audio_output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
torchaudio.save(
|
||||
str(audio_output_path),
|
||||
audio.cpu(),
|
||||
sample_rate=audio_sample_rate,
|
||||
)
|
||||
duration = audio.shape[1] / audio_sample_rate
|
||||
print(f"✓ Audio saved: {duration:.2f}s at {audio_sample_rate}Hz")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Generation complete!")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
#!/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 pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import torch
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
MofNCompleteColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
from torch.utils.data import DataLoader, Dataset
|
||||
from transformers.utils.logging import disable_progress_bar
|
||||
|
||||
from ltx_trainer import logger
|
||||
from ltx_trainer.model_loader import 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 _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())
|
||||
# Convert media path to embedding output path (same structure, .pt extension)
|
||||
output_path = str(media_path.with_suffix(".pt"))
|
||||
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())
|
||||
# Convert media path to embedding output path (same structure, .pt extension)
|
||||
output_path = str(media_path.with_suffix(".pt"))
|
||||
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())
|
||||
# Convert media path to embedding output path (same structure, .pt extension)
|
||||
output_path = str(media_path.with_suffix(".pt"))
|
||||
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(
|
||||
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",
|
||||
) -> None:
|
||||
"""
|
||||
Process captions and save text embeddings.
|
||||
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
|
||||
"""
|
||||
|
||||
console = Console()
|
||||
|
||||
# Create dataset
|
||||
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)
|
||||
|
||||
# Load text encoder
|
||||
with console.status("[bold]Loading Gemma text encoder...", spinner="dots"):
|
||||
text_encoder = load_text_encoder(model_path, text_encoder_path, device=device, dtype=torch.bfloat16)
|
||||
|
||||
logger.info("Text encoder loaded successfully")
|
||||
|
||||
# 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
|
||||
|
||||
# Create dataloader
|
||||
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=2)
|
||||
|
||||
# Process batches
|
||||
total_batches = len(dataloader)
|
||||
logger.info(f"Processing captions in {total_batches:,} 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 _preprocess_text (returns embeddings before connector)
|
||||
# This is what we want to save - the connector is applied during training
|
||||
with torch.inference_mode():
|
||||
# TODO(batch-tokenization): When tokenizer supports batching, encode all prompts at once:
|
||||
# prompt_embeds, prompt_attention_mask = text_encoder._preprocess_text(batch["prompt"]) # noqa: ERA001
|
||||
# For now, process one at a time:
|
||||
for i in range(len(batch["prompt"])):
|
||||
prompt_embeds, prompt_attention_mask = text_encoder._preprocess_text(
|
||||
batch["prompt"][i], padding_side="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 = {
|
||||
"prompt_embeds": prompt_embeds[0].cpu().contiguous(),
|
||||
"prompt_attention_mask": prompt_attention_mask[0].cpu().contiguous(),
|
||||
}
|
||||
|
||||
output_file = output_path / output_rel_path
|
||||
torch.save(embedding_data, output_file)
|
||||
|
||||
progress.advance(task)
|
||||
|
||||
logger.info(f"Processed {len(dataset):,} captions. Embeddings saved to {output_path}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
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",
|
||||
),
|
||||
) -> None:
|
||||
"""Process text captions and save embeddings for video generation training.
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Preprocess a video dataset by computing video clips latents and text captions embeddings.
|
||||
This script provides a command-line interface for preprocessing video datasets by computing
|
||||
latent representations of video clips and text embeddings of their captions. The preprocessed
|
||||
data can be used to accelerate training of video generation models and to save GPU memory.
|
||||
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
|
||||
The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from decode_latents import LatentsDecoder
|
||||
from process_captions import compute_captions_embeddings
|
||||
from process_videos import compute_latents, parse_resolution_buckets
|
||||
from rich.console import Console
|
||||
|
||||
from ltx_trainer import logger
|
||||
|
||||
console = Console()
|
||||
app = typer.Typer(
|
||||
pretty_exceptions_enable=False,
|
||||
no_args_is_help=True,
|
||||
help="Preprocess a video dataset by computing video clips latents and text captions embeddings. "
|
||||
"The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.",
|
||||
)
|
||||
|
||||
|
||||
def preprocess_dataset( # noqa: PLR0913
|
||||
dataset_file: str,
|
||||
caption_column: str,
|
||||
video_column: str,
|
||||
resolution_buckets: list[tuple[int, int, int]],
|
||||
batch_size: int,
|
||||
output_dir: str | None,
|
||||
lora_trigger: str | None,
|
||||
vae_tiling: bool,
|
||||
decode: bool,
|
||||
model_path: str,
|
||||
text_encoder_path: str,
|
||||
device: str,
|
||||
remove_llm_prefixes: bool = False,
|
||||
reference_column: str | None = None,
|
||||
with_audio: bool = False,
|
||||
) -> None:
|
||||
"""Run the preprocessing pipeline with the given arguments."""
|
||||
# Validate dataset file
|
||||
_validate_dataset_file(dataset_file)
|
||||
|
||||
# Set up output directories
|
||||
output_base = Path(output_dir) if output_dir else Path(dataset_file).parent / ".precomputed"
|
||||
conditions_dir = output_base / "conditions"
|
||||
latents_dir = output_base / "latents"
|
||||
|
||||
if lora_trigger:
|
||||
logger.info(f'LoRA trigger word "{lora_trigger}" will be prepended to all captions')
|
||||
|
||||
# Process captions using the dedicated function
|
||||
compute_captions_embeddings(
|
||||
dataset_file=dataset_file,
|
||||
output_dir=str(conditions_dir),
|
||||
model_path=model_path,
|
||||
text_encoder_path=text_encoder_path,
|
||||
caption_column=caption_column,
|
||||
media_column=video_column,
|
||||
lora_trigger=lora_trigger,
|
||||
remove_llm_prefixes=remove_llm_prefixes,
|
||||
batch_size=batch_size,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Process videos using the dedicated function
|
||||
audio_latents_dir = None
|
||||
if with_audio:
|
||||
logger.info("Audio preprocessing enabled - will extract and encode audio from videos")
|
||||
audio_latents_dir = output_base / "audio_latents"
|
||||
|
||||
compute_latents(
|
||||
dataset_file=dataset_file,
|
||||
video_column=video_column,
|
||||
resolution_buckets=resolution_buckets,
|
||||
output_dir=str(latents_dir),
|
||||
model_path=model_path,
|
||||
batch_size=batch_size,
|
||||
device=device,
|
||||
vae_tiling=vae_tiling,
|
||||
with_audio=with_audio,
|
||||
audio_output_dir=str(audio_latents_dir) if audio_latents_dir else None,
|
||||
)
|
||||
|
||||
# Process reference videos if reference_column is provided
|
||||
if reference_column:
|
||||
logger.info("Processing reference videos for IC-LoRA training...")
|
||||
reference_latents_dir = output_base / "reference_latents"
|
||||
|
||||
compute_latents(
|
||||
dataset_file=dataset_file,
|
||||
main_media_column=video_column,
|
||||
video_column=reference_column,
|
||||
resolution_buckets=resolution_buckets,
|
||||
output_dir=str(reference_latents_dir),
|
||||
model_path=model_path,
|
||||
batch_size=batch_size,
|
||||
device=device,
|
||||
vae_tiling=vae_tiling,
|
||||
)
|
||||
|
||||
# Handle decoding if requested (for verification)
|
||||
if decode:
|
||||
logger.info("Decoding latents for verification...")
|
||||
|
||||
decoder = LatentsDecoder(
|
||||
model_path=model_path,
|
||||
device=device,
|
||||
vae_tiling=vae_tiling,
|
||||
with_audio=with_audio,
|
||||
)
|
||||
decoder.decode(latents_dir, output_base / "decoded_videos")
|
||||
|
||||
# Also decode reference videos if they exist
|
||||
if reference_column:
|
||||
reference_latents_dir = output_base / "reference_latents"
|
||||
if reference_latents_dir.exists():
|
||||
logger.info("Decoding reference videos...")
|
||||
decoder.decode(reference_latents_dir, output_base / "decoded_reference_videos")
|
||||
|
||||
# Decode audio latents if they exist
|
||||
if with_audio and audio_latents_dir and audio_latents_dir.exists():
|
||||
logger.info("Decoding audio latents...")
|
||||
decoder.decode_audio(audio_latents_dir, output_base / "decoded_audio")
|
||||
|
||||
# Print summary
|
||||
logger.info(f"Dataset preprocessing complete! Results saved to {output_base}")
|
||||
if reference_column:
|
||||
logger.info("Reference videos processed and saved to reference_latents/ directory for IC-LoRA training")
|
||||
if with_audio:
|
||||
logger.info("Audio latents saved to audio_latents/ directory for audio-video training")
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main( # noqa: PLR0913
|
||||
dataset_path: str = typer.Argument(
|
||||
...,
|
||||
help="Path to metadata file (CSV/JSON/JSONL) containing captions and video paths",
|
||||
),
|
||||
resolution_buckets: str = typer.Option(
|
||||
...,
|
||||
help='Resolution buckets in format "WxHxF;WxHxF;..." (e.g. "768x768x25;512x512x49")',
|
||||
),
|
||||
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",
|
||||
),
|
||||
video_column: str = typer.Option(
|
||||
default="media_path",
|
||||
help="Column name containing video paths in the dataset JSON/JSONL/CSV file",
|
||||
),
|
||||
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 (activates the LoRA during inference)",
|
||||
),
|
||||
decode: bool = typer.Option(
|
||||
default=False,
|
||||
help="Decode and save latents after encoding (videos and audio) for verification",
|
||||
),
|
||||
remove_llm_prefixes: bool = typer.Option(
|
||||
default=False,
|
||||
help="Remove LLM prefixes from captions",
|
||||
),
|
||||
reference_column: str | None = typer.Option(
|
||||
default=None,
|
||||
help="Column name containing reference video paths (for video-to-video training)",
|
||||
),
|
||||
with_audio: bool = typer.Option(
|
||||
default=False,
|
||||
help="Extract and encode audio from video files",
|
||||
),
|
||||
) -> None:
|
||||
"""Preprocess a video dataset by computing and saving latents and text embeddings.
|
||||
The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.
|
||||
This script is designed for LTX-2 models which use the Gemma text encoder.
|
||||
Examples:
|
||||
# Process a dataset with LTX-2 model
|
||||
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
|
||||
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma
|
||||
# Process dataset with custom column names
|
||||
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
|
||||
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
|
||||
--caption-column "text" --video-column "video_path"
|
||||
# Process dataset with reference videos for IC-LoRA training
|
||||
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
|
||||
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
|
||||
--reference-column "reference_path"
|
||||
# Process dataset with audio for audio-video training
|
||||
python scripts/process_dataset.py dataset.json --resolution-buckets 768x512x97 \\
|
||||
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
|
||||
--with-audio
|
||||
"""
|
||||
parsed_resolution_buckets = parse_resolution_buckets(resolution_buckets)
|
||||
|
||||
if len(parsed_resolution_buckets) > 1:
|
||||
logger.warning(
|
||||
"Using multiple resolution buckets. "
|
||||
"When training with multiple resolution buckets, you must use a batch size of 1."
|
||||
)
|
||||
|
||||
preprocess_dataset(
|
||||
dataset_file=dataset_path,
|
||||
caption_column=caption_column,
|
||||
video_column=video_column,
|
||||
resolution_buckets=parsed_resolution_buckets,
|
||||
batch_size=batch_size,
|
||||
output_dir=output_dir,
|
||||
lora_trigger=lora_trigger,
|
||||
vae_tiling=vae_tiling,
|
||||
decode=decode,
|
||||
model_path=model_path,
|
||||
text_encoder_path=text_encoder_path,
|
||||
device=device,
|
||||
remove_llm_prefixes=remove_llm_prefixes,
|
||||
reference_column=reference_column,
|
||||
with_audio=with_audio,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+825
@@ -0,0 +1,825 @@
|
||||
#!/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
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torchaudio
|
||||
import typer
|
||||
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
|
||||
from torchvision import transforms
|
||||
from torchvision.transforms import InterpolationMode
|
||||
from torchvision.transforms.functional import crop, resize, to_tensor
|
||||
from transformers.utils.logging import disable_progress_bar
|
||||
|
||||
from ltx_core.model.audio_vae import AudioProcessor
|
||||
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
|
||||
|
||||
app = typer.Typer(
|
||||
pretty_exceptions_enable=False,
|
||||
no_args_is_help=True,
|
||||
help="Process videos/images and save latent representations for video generation training.",
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> 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
|
||||
"""
|
||||
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
|
||||
|
||||
# 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(lambda x: x.clamp_(0, 1)),
|
||||
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(video_path.relative_to(data_root))
|
||||
media_relative_path = str(self.main_media_paths[index].relative_to(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 to match video duration."""
|
||||
try:
|
||||
# torchaudio can extract audio from video files directly
|
||||
# waveform shape: [channels, samples]
|
||||
waveform, sample_rate = torchaudio.load(str(video_path))
|
||||
|
||||
# Trim or pad to target duration
|
||||
target_samples = int(target_duration * sample_rate)
|
||||
current_samples = waveform.shape[-1]
|
||||
|
||||
if current_samples > target_samples:
|
||||
# Trim to target duration
|
||||
waveform = waveform[..., :target_samples]
|
||||
elif current_samples < target_samples:
|
||||
# Pad with zeros to target duration
|
||||
padding = target_samples - current_samples
|
||||
waveform = torch.nn.functional.pad(waveform, (0, padding))
|
||||
logger.warning(f"Padded audio to {target_duration:.2f} seconds for {video_path}")
|
||||
|
||||
return {"waveform": waveform, "sample_rate": sample_rate}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract audio from {video_path}: {e}")
|
||||
return None
|
||||
|
||||
def _load_video_paths(self, column: str) -> list[Path]:
|
||||
"""Load video paths from the specified data source."""
|
||||
if self.dataset_file.suffix == ".csv":
|
||||
return self._load_video_paths_from_csv(column)
|
||||
elif self.dataset_file.suffix == ".json":
|
||||
return self._load_video_paths_from_json(column)
|
||||
elif self.dataset_file.suffix == ".jsonl":
|
||||
return self._load_video_paths_from_jsonl(column)
|
||||
else:
|
||||
raise ValueError("Expected `dataset_file` to be a path to a CSV, JSON, or JSONL file.")
|
||||
|
||||
def _load_video_paths_from_csv(self, column: str) -> list[Path]:
|
||||
"""Load video paths from a CSV file."""
|
||||
df = pd.read_csv(self.dataset_file)
|
||||
if column not in df.columns:
|
||||
raise ValueError(f"Column '{column}' not found in CSV file")
|
||||
|
||||
data_root = self.dataset_file.parent
|
||||
video_paths = [data_root / Path(line.strip()) for line in df[column].tolist()]
|
||||
|
||||
# Validate that all paths exist
|
||||
invalid_paths = [path for path in video_paths if not path.is_file()]
|
||||
if invalid_paths:
|
||||
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
|
||||
|
||||
return video_paths
|
||||
|
||||
def _load_video_paths_from_json(self, column: str) -> list[Path]:
|
||||
"""Load video paths from a JSON file."""
|
||||
with open(self.dataset_file, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("JSON file must contain a list of objects")
|
||||
|
||||
data_root = self.dataset_file.parent
|
||||
video_paths = []
|
||||
for entry in data:
|
||||
if column not in entry:
|
||||
raise ValueError(f"Key '{column}' not found in JSON entry")
|
||||
video_paths.append(data_root / Path(entry[column].strip()))
|
||||
|
||||
# Validate that all paths exist
|
||||
invalid_paths = [path for path in video_paths if not path.is_file()]
|
||||
if invalid_paths:
|
||||
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
|
||||
|
||||
return video_paths
|
||||
|
||||
def _load_video_paths_from_jsonl(self, column: str) -> list[Path]:
|
||||
"""Load video paths from a JSONL file."""
|
||||
data_root = self.dataset_file.parent
|
||||
video_paths = []
|
||||
with open(self.dataset_file, "r", encoding="utf-8") as file:
|
||||
for line in file:
|
||||
entry = json.loads(line)
|
||||
if column not in entry:
|
||||
raise ValueError(f"Key '{column}' not found in JSONL entry")
|
||||
video_paths.append(data_root / Path(entry[column].strip()))
|
||||
|
||||
# Validate that all paths exist
|
||||
invalid_paths = [path for path in video_paths if not path.is_file()]
|
||||
if invalid_paths:
|
||||
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
|
||||
|
||||
return video_paths
|
||||
|
||||
def _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]
|
||||
|
||||
# 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_latents( # noqa: 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,
|
||||
) -> None:
|
||||
"""
|
||||
Process videos and save latent representations.
|
||||
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)
|
||||
"""
|
||||
# Validate audio parameters
|
||||
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)
|
||||
|
||||
# Create dataset
|
||||
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,
|
||||
)
|
||||
logger.info(f"Loaded {len(dataset)} valid media files")
|
||||
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set up audio output directory if needed
|
||||
audio_output_path = None
|
||||
if with_audio:
|
||||
audio_output_path = Path(audio_output_dir)
|
||||
audio_output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load video VAE encoder
|
||||
with console.status(f"[bold]Loading video VAE encoder from [cyan]{model_path}[/]...", spinner="dots"):
|
||||
vae = load_video_vae_encoder(model_path, device=torch_device, dtype=torch.bfloat16)
|
||||
|
||||
if vae_tiling:
|
||||
vae.enable_tiling()
|
||||
|
||||
# Load audio VAE encoder and audio processor if needed
|
||||
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.
|
||||
)
|
||||
# Create audio processor for waveform-to-spectrogram conversion
|
||||
audio_processor = AudioProcessor(
|
||||
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)
|
||||
|
||||
# Create dataloader
|
||||
# Note: batch_size=1 required when with_audio because audio extraction can fail for some videos,
|
||||
# and the default collate function can't handle mixed None/dict values across a batch.
|
||||
if with_audio and batch_size > 1:
|
||||
logger.warning("Audio processing requires batch_size=1. Overriding batch_size to 1.")
|
||||
batch_size = 1
|
||||
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=4)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Index into batch to get this item's latents
|
||||
latent_data = {
|
||||
"latents": video_latent_data["latents"][i].cpu().contiguous(), # [C, F', H', W']
|
||||
"num_frames": video_latent_data["num_frames"],
|
||||
"height": video_latent_data["height"],
|
||||
"width": video_latent_data["width"],
|
||||
"fps": batch["video_metadata"]["fps"][i].item(),
|
||||
}
|
||||
|
||||
torch.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 = {
|
||||
"waveform": audio_batch["waveform"][i],
|
||||
"sample_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"],
|
||||
}
|
||||
|
||||
torch.save(audio_save_data, audio_output_file)
|
||||
audio_success_count += 1
|
||||
else:
|
||||
# Video has no audio track
|
||||
audio_skip_count += 1
|
||||
|
||||
progress.advance(task)
|
||||
|
||||
# Log summary
|
||||
logger.info(f"Processed {len(dataset)} videos. Latents saved to {output_path}")
|
||||
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,
|
||||
) -> 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
|
||||
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)
|
||||
|
||||
# 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 encode_audio(
|
||||
audio_vae_encoder: torch.nn.Module,
|
||||
audio_processor: torch.nn.Module,
|
||||
audio_data: dict[str, torch.Tensor | int],
|
||||
) -> 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_data: Dict with {"waveform": Tensor[channels, samples], "sample_rate": int}
|
||||
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_data["waveform"].to(device=device, dtype=dtype)
|
||||
sample_rate = audio_data["sample_rate"]
|
||||
|
||||
# Add batch dimension if needed: [channels, samples] -> [batch, channels, samples]
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
|
||||
# Calculate duration
|
||||
duration = waveform.shape[-1] / sample_rate
|
||||
|
||||
# Convert waveform to mel spectrogram using AudioProcessor
|
||||
mel_spectrogram = audio_processor.waveform_to_mel(waveform, waveform_sample_rate=sample_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,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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)",
|
||||
),
|
||||
) -> 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.
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Executable
+417
@@ -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()
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/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]
|
||||
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."""
|
||||
# Load the configuration from the YAML 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)
|
||||
|
||||
# Convert the loaded data to the LtxTrainerConfig object
|
||||
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
|
||||
|
||||
# Initialize the training process
|
||||
trainer = LtxvTrainer(trainer_config)
|
||||
trainer.train(disable_progress_bars=disable_progress_bars)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Reference in New Issue
Block a user