File size: 19,091 Bytes
be7e4b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
#!/usr/bin/env python3

"""
Preprocess a media dataset for LTX-2 training.
Automatically detects dataset columns and processes each according to a convention table.
Column names determine what gets encoded and where outputs go β€” no per-role CLI flags needed.
Convention table:
    video            β†’ Video VAE    β†’ latents/
    audio            β†’ Audio VAE    β†’ audio_latents/
    reference_video  β†’ Video VAE    β†’ reference_latents/
    reference_image  β†’ Video VAE    β†’ reference_image_latents/  (single PNG/JPG β†’ 1-frame latent)
    reference_audio  β†’ Audio VAE    β†’ reference_audio_latents/
    video_mask       β†’ (downsample) β†’ video_masks/
    audio_mask       β†’ (downsample) β†’ audio_masks/
    caption          β†’ Text encoder β†’ conditions/
Legacy aliases: media_path β†’ video, ref_media_path β†’ reference_video
Basic usage:
    python scripts/process_dataset.py /path/to/dataset.json --resolution-buckets 768x768x49 \\
        --model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma
"""

from pathlib import Path

import typer
from decode_latents import LatentsDecoder
from process_captions import compute_captions_embeddings
from process_videos import (
    compute_audio_latents,
    compute_audio_masks,
    compute_latents,
    compute_reference_image_latents,
    compute_scaled_resolution_buckets,
    compute_video_masks,
    detect_dataset_columns,
    parse_resolution_buckets,
)
from rich.console import Console

from ltx_trainer import logger
from ltx_trainer.gpu_utils import free_gpu_memory_context

console = Console()

app = typer.Typer(
    pretty_exceptions_enable=False,
    no_args_is_help=True,
    help="Preprocess a media dataset for LTX-2 training. "
    "Automatically detects columns (video, audio, reference_video, reference_image, reference_audio, caption) "
    "and processes each with the appropriate encoder.",
)

_KNOWN_ROLES = {"video", "audio", "reference_video", "reference_audio", "reference_image", "video_mask", "audio_mask", "caption"}
_LEGACY_ALIASES = {"media_path": "video", "ref_media_path": "reference_video"}


def preprocess_dataset(  # noqa: PLR0912, PLR0913, PLR0915
    dataset_file: str,
    resolution_buckets: list[tuple[int, int, int]] | None,
    model_path: str,
    text_encoder_path: str,
    device: str,
    output_dir: str | None = None,
    video_column: str | None = None,
    caption_column: str | None = None,
    batch_size: int = 1,
    lora_trigger: str | None = None,
    vae_tiling: bool = False,
    decode: bool = False,
    remove_llm_prefixes: bool = False,
    reference_downscale_factor: int = 1,
    reference_temporal_scale_factor: int = 1,
    skip_audio: bool = False,
    audio_durations: list[float] | None = None,
    load_text_encoder_in_8bit: bool = False,
    overwrite: bool = False,
) -> None:
    """Run the preprocessing pipeline with convention-based column detection."""
    _validate_dataset_file(dataset_file)

    # Detect columns and resolve roles
    dataset_columns = detect_dataset_columns(dataset_file)
    roles = _resolve_columns(dataset_columns, video_column, caption_column)

    # Log detected roles
    for role, col in sorted(roles.items()):
        alias_note = f" (alias for '{role}')" if col != role else ""
        logger.info(f"Detected column '{col}'{alias_note} β†’ {role}")

    # Validate: need at least caption
    if "caption" not in roles:
        raise ValueError(
            f"No caption column found. Dataset has columns: {dataset_columns}. "
            f"Expected 'caption' or use --caption-column to specify."
        )

    # Validate: need video or audio
    has_video = "video" in roles
    has_audio = "audio" in roles
    if not has_video and not has_audio:
        raise ValueError(
            f"No media column found. Dataset has columns: {dataset_columns}. "
            f"Expected 'video', 'audio', or 'media_path' (legacy)."
        )

    # Validate: video modes need resolution buckets
    if has_video and not resolution_buckets:
        raise ValueError("--resolution-buckets is required when the dataset has a video column.")

    output_base = Path(output_dir) if output_dir else Path(dataset_file).parent / ".precomputed"

    if lora_trigger:
        logger.info(f'LoRA trigger word "{lora_trigger}" will be prepended to all captions')

    # --- Phase 1: Text encoder ---
    with free_gpu_memory_context():
        compute_captions_embeddings(
            dataset_file=dataset_file,
            output_dir=str(output_base / "conditions"),
            model_path=model_path,
            text_encoder_path=text_encoder_path,
            caption_column=roles["caption"],
            media_column=roles.get("video") or roles.get("audio") or roles["caption"],
            lora_trigger=lora_trigger,
            remove_llm_prefixes=remove_llm_prefixes,
            batch_size=batch_size,
            device=device,
            load_in_8bit=load_text_encoder_in_8bit,
            overwrite=overwrite,
        )

    # --- Phase 2: Video VAE (video, reference_video) ---
    if has_video and resolution_buckets:
        # Determine if audio should be auto-extracted from video files
        auto_audio = not skip_audio and "audio" not in roles

        audio_latents_dir = str(output_base / "audio_latents") if auto_audio else None
        if auto_audio:
            logger.info("Audio will be auto-extracted from video files (use --skip-audio to disable)")

        with free_gpu_memory_context():
            compute_latents(
                dataset_file=dataset_file,
                video_column=roles["video"],
                resolution_buckets=resolution_buckets,
                output_dir=str(output_base / "latents"),
                model_path=model_path,
                batch_size=batch_size,
                device=device,
                vae_tiling=vae_tiling,
                with_audio=auto_audio,
                audio_output_dir=audio_latents_dir,
                overwrite=overwrite,
            )

        # Process reference video if present
        if "reference_video" in roles:
            if reference_downscale_factor > 1 and len(resolution_buckets) > 1:
                raise ValueError(
                    "When using --reference-downscale-factor > 1, only a single resolution bucket is supported."
                )
            if reference_temporal_scale_factor > 1 and len(resolution_buckets) > 1:
                raise ValueError(
                    "When using --reference-temporal-scale-factor > 1, only a single resolution bucket is supported."
                )

            reference_buckets = compute_scaled_resolution_buckets(resolution_buckets, reference_downscale_factor)
            if reference_downscale_factor > 1:
                logger.info(f"Processing reference videos at 1/{reference_downscale_factor} resolution...")
            if reference_temporal_scale_factor > 1:
                logger.info(
                    f"Temporally subsampling reference videos by {reference_temporal_scale_factor}x "
                    f"(VAE-aligned pattern)..."
                )

            with free_gpu_memory_context():
                compute_latents(
                    dataset_file=dataset_file,
                    main_media_column=roles["video"],
                    video_column=roles["reference_video"],
                    resolution_buckets=reference_buckets,
                    output_dir=str(output_base / "reference_latents"),
                    model_path=model_path,
                    batch_size=batch_size,
                    device=device,
                    vae_tiling=vae_tiling,
                    overwrite=overwrite,
                    temporal_subsample_factor=reference_temporal_scale_factor,
                )

        # Process reference image if present (single PNG/JPG encoded as a 1-frame video latent).
        # Downscale it by the SAME reference_downscale_factor as reference_video so a single
        # (global) reference spatial-scale factor in the training strategy applies correctly to
        # both references. (factor=1 β†’ unchanged, so this is backward-compatible.)
        if "reference_image" in roles:
            logger.info("Processing reference images as single-frame latents...")
            with free_gpu_memory_context():
                compute_reference_image_latents(
                    dataset_file=dataset_file,
                    image_column=roles["reference_image"],
                    resolution_buckets=compute_scaled_resolution_buckets(
                        resolution_buckets, reference_downscale_factor
                    ),
                    output_dir=str(output_base / "reference_image_latents"),
                    model_path=model_path,
                    main_media_column=roles.get("video"),
                    device=device,
                    vae_tiling=vae_tiling,
                    overwrite=overwrite,
                )

    # --- Phase 2b: Masks (video_mask, audio_mask) β€” processed after video latents for alignment ---
    if "video_mask" in roles and has_video:
        compute_video_masks(
            dataset_file=dataset_file,
            mask_column=roles["video_mask"],
            latents_dir=str(output_base / "latents"),
            output_dir=str(output_base / "video_masks"),
            main_media_column=roles["video"],
        )

    # --- Phase 3: Audio VAE (audio, reference_audio) ---
    audio_roles_to_process = [
        ("audio", "audio_latents"),
        ("reference_audio", "reference_audio_latents"),
    ]
    active_audio_roles = [(role, subdir) for role, subdir in audio_roles_to_process if role in roles]

    if active_audio_roles:
        # Determine audio duration constraint: video bucket β†’ max_duration, or explicit buckets
        max_audio_duration = None
        audio_duration_buckets = None
        if has_video and resolution_buckets:
            max_audio_duration = max(f for f, _h, _w in resolution_buckets) / 25.0
        elif audio_durations:
            audio_duration_buckets = audio_durations

        for role, output_subdir in active_audio_roles:
            with free_gpu_memory_context():
                compute_audio_latents(
                    dataset_file=dataset_file,
                    audio_column=roles[role],
                    output_dir=str(output_base / output_subdir),
                    model_path=model_path,
                    main_media_column=roles.get("video"),
                    max_duration=max_audio_duration,
                    duration_buckets=audio_duration_buckets,
                    device=device,
                    overwrite=overwrite,
                )

    # --- Phase 4: Audio masks (after audio latents exist for temporal alignment) ---
    if "audio_mask" in roles:
        audio_latents_source = output_base / "audio_latents"
        if audio_latents_source.exists():
            compute_audio_masks(
                dataset_file=dataset_file,
                mask_column=roles["audio_mask"],
                audio_latents_dir=str(audio_latents_source),
                output_dir=str(output_base / "audio_masks"),
                main_media_column=roles.get("video") or roles.get("audio"),
            )
        else:
            logger.warning("audio_mask column found but no audio_latents/ β€” run with audio first")

    # --- Decode for verification ---
    if decode:
        logger.info("Decoding latents for verification...")
        decoder = LatentsDecoder(model_path=model_path, device=device, vae_tiling=vae_tiling, with_audio=has_audio)
        if has_video:
            decoder.decode(output_base / "latents", output_base / "decoded_videos")
        if "reference_video" in roles and (output_base / "reference_latents").exists():
            decoder.decode(output_base / "reference_latents", output_base / "decoded_reference_videos")

    # --- Summary ---
    logger.info(f"Dataset preprocessing complete! Results saved to {output_base}")
    produced = [d.name for d in output_base.iterdir() if d.is_dir() and not d.name.startswith("decoded")]
    logger.info(f"Output directories: {', '.join(sorted(produced))}")


def _validate_dataset_file(dataset_path: str) -> None:
    """Validate that the dataset file exists and has the correct format."""
    dataset_file = Path(dataset_path)
    if not dataset_file.exists():
        raise FileNotFoundError(f"Dataset file does not exist: {dataset_file}")
    if not dataset_file.is_file():
        raise ValueError(f"Dataset path must be a file, not a directory: {dataset_file}")
    if dataset_file.suffix.lower() not in [".csv", ".json", ".jsonl"]:
        raise ValueError(f"Dataset file must be CSV, JSON, or JSONL format: {dataset_file}")


def _resolve_columns(
    dataset_columns: set[str],
    video_column_override: str | None = None,
    caption_column_override: str | None = None,
) -> dict[str, str]:
    """Map canonical role names to actual dataset column names.
    Returns a dict of role β†’ column_name for recognized roles found in the dataset.
    """
    roles: dict[str, str] = {}
    for col in dataset_columns:
        role = _LEGACY_ALIASES.get(col, col)
        if role in _KNOWN_ROLES:
            roles[role] = col

    if video_column_override and video_column_override in dataset_columns:
        roles["video"] = video_column_override
    if caption_column_override and caption_column_override in dataset_columns:
        roles["caption"] = caption_column_override

    return roles


@app.command()
def main(  # noqa: PLR0913
    dataset_path: str = typer.Argument(
        ...,
        help="Path to metadata file (CSV/JSON/JSONL) with columns matching the convention table",
    ),
    resolution_buckets: str | None = typer.Option(
        default=None,
        help='Resolution buckets in format "WxHxF;WxHxF;..." (e.g. "768x768x25"). '
        "Required when dataset has a video column.",
    ),
    model_path: str = typer.Option(
        ...,
        help="Path to LTX-2 checkpoint (.safetensors file)",
    ),
    text_encoder_path: str = typer.Option(
        ...,
        help="Path to Gemma text encoder directory",
    ),
    caption_column: str | None = typer.Option(
        default=None,
        help="Override: treat this column as 'caption' (default: auto-detect 'caption')",
    ),
    video_column: str | None = typer.Option(
        default=None,
        help="Override: treat this column as 'video' (default: auto-detect 'video' or 'media_path')",
    ),
    batch_size: int = typer.Option(
        default=1,
        help="Batch size for preprocessing",
    ),
    device: str = typer.Option(
        default="cuda",
        help="Device to use for computation",
    ),
    vae_tiling: bool = typer.Option(
        default=False,
        help="Enable VAE tiling for larger video resolutions",
    ),
    output_dir: str | None = typer.Option(
        default=None,
        help="Output directory (defaults to .precomputed in dataset directory)",
    ),
    lora_trigger: str | None = typer.Option(
        default=None,
        help="Optional trigger word to prepend to each caption",
    ),
    decode: bool = typer.Option(
        default=False,
        help="Decode and save latents after encoding for verification",
    ),
    remove_llm_prefixes: bool = typer.Option(
        default=False,
        help="Remove LLM prefixes from captions",
    ),
    skip_audio: bool = typer.Option(
        default=False,
        help="Don't extract audio from video files (audio extraction is on by default)",
    ),
    audio_durations: str | None = typer.Option(
        default=None,
        help='Audio duration buckets in seconds for audio-only datasets (e.g. "2.0;4.0;8.0"). '
        "When set, audio files are trimmed to the best matching duration. "
        "Not needed when a video column is present (audio duration derived from video bucket).",
    ),
    with_audio: bool = typer.Option(
        default=False,
        hidden=True,
        help="[DEPRECATED: audio is now on by default, use --skip-audio to disable]",
    ),
    load_text_encoder_in_8bit: bool = typer.Option(
        default=False,
        help="Load the Gemma text encoder in 8-bit precision to save GPU memory",
    ),
    reference_downscale_factor: int = typer.Option(
        default=1,
        help="Downscale factor for reference video resolution (e.g., 2 = half resolution for IC-LoRA)",
    ),
    reference_temporal_scale_factor: int = typer.Option(
        default=1,
        help="Temporal subsampling factor for reference videos (e.g., 2 = half frame rate, "
        "VAE-aligned: keeps frame 0, then every Nth frame from frame 1 onwards)",
    ),
    overwrite: bool = typer.Option(
        default=False,
        help="Re-compute every item even if its output exists. Use when rerunning with "
        "changed parameters (different model, resolution, etc.) so stale outputs are replaced.",
    ),
) -> None:
    """Preprocess a media dataset for LTX-2 training.
    See module docstring for the convention table. Audio is auto-extracted from
    video files by default β€” use --skip-audio to disable.
    For multi-GPU preprocessing, invoke under ``accelerate launch`` -- each process
    will handle an interleaved shard of the dataset.
    """
    # Handle deprecated --with-audio flag
    if with_audio:
        logger.warning(
            "--with-audio is deprecated. Audio extraction is now on by default. Use --skip-audio to disable."
        )

    parsed_buckets = parse_resolution_buckets(resolution_buckets) if resolution_buckets else None

    if parsed_buckets and len(parsed_buckets) > 1:
        logger.warning("Using multiple resolution buckets. Training batch size must be 1.")

    if reference_downscale_factor < 1:
        raise typer.BadParameter("--reference-downscale-factor must be >= 1")

    if reference_temporal_scale_factor < 1:
        raise typer.BadParameter("--reference-temporal-scale-factor must be >= 1")

    parsed_audio_durations = None
    if audio_durations:
        parsed_audio_durations = [float(d) for d in audio_durations.split(";")]
        if any(d <= 0 for d in parsed_audio_durations):
            raise typer.BadParameter("All audio durations must be positive")

    preprocess_dataset(
        dataset_file=dataset_path,
        resolution_buckets=parsed_buckets,
        model_path=model_path,
        text_encoder_path=text_encoder_path,
        device=device,
        output_dir=output_dir,
        video_column=video_column,
        caption_column=caption_column,
        batch_size=batch_size,
        lora_trigger=lora_trigger,
        vae_tiling=vae_tiling,
        decode=decode,
        remove_llm_prefixes=remove_llm_prefixes,
        reference_downscale_factor=reference_downscale_factor,
        reference_temporal_scale_factor=reference_temporal_scale_factor,
        skip_audio=skip_audio,
        audio_durations=parsed_audio_durations,
        load_text_encoder_in_8bit=load_text_encoder_in_8bit,
        overwrite=overwrite,
    )


if __name__ == "__main__":
    app()