Automated PR - 2026-05-11
This commit is contained in:
@@ -5,6 +5,7 @@ This package provides ready-to-use pipelines for video generation:
|
||||
- TI2VidTwoStagesPipeline: Two-stage generation with upsampling
|
||||
- DistilledPipeline: Fast distilled two-stage generation
|
||||
- ICLoraPipeline: Image/video conditioning with distilled LoRA
|
||||
- LipDubPipeline: Lip dubbing with IC-LoRA and audio conditioning
|
||||
- KeyframeInterpolationPipeline: Keyframe-based video interpolation
|
||||
- RetakePipeline: Regenerate a time region (retake) of an existing video
|
||||
For more detailed components and utilities, import from specific submodules
|
||||
@@ -15,6 +16,7 @@ from ltx_pipelines.a2vid_two_stage import A2VidPipelineTwoStage
|
||||
from ltx_pipelines.distilled import DistilledPipeline
|
||||
from ltx_pipelines.ic_lora import ICLoraPipeline
|
||||
from ltx_pipelines.keyframe_interpolation import KeyframeInterpolationPipeline
|
||||
from ltx_pipelines.lipdub import LipDubPipeline
|
||||
from ltx_pipelines.retake import RetakePipeline
|
||||
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
|
||||
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
||||
@@ -24,6 +26,7 @@ __all__ = [
|
||||
"DistilledPipeline",
|
||||
"ICLoraPipeline",
|
||||
"KeyframeInterpolationPipeline",
|
||||
"LipDubPipeline",
|
||||
"RetakePipeline",
|
||||
"TI2VidOneStagePipeline",
|
||||
"TI2VidTwoStagesPipeline",
|
||||
|
||||
@@ -48,7 +48,7 @@ from ltx_core.model.video_vae import TilingConfig, VideoEncoder
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.tiling import DimensionTilingConfig, TileCountConfig
|
||||
from ltx_core.tools import VideoLatentTools
|
||||
from ltx_core.types import VideoLatentShape
|
||||
from ltx_core.types import VideoLatentShape, VideoPixelShape
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
DiffusionStage,
|
||||
ImageConditioner,
|
||||
@@ -412,7 +412,22 @@ class HDRICLoraPipeline:
|
||||
high_quality_hdr=high_quality_hdr,
|
||||
)
|
||||
)
|
||||
with self.stage_2.model_context() as transformer:
|
||||
# video_tools is required by TiledDataParallelBuilder when stage_2 is
|
||||
# wrapped for multi-GPU
|
||||
stage2_video_tools = VideoLatentTools(
|
||||
VideoLatentPatchifier(patch_size=1),
|
||||
VideoLatentShape.from_pixel_shape(
|
||||
VideoPixelShape(
|
||||
batch=1,
|
||||
frames=gen_num_frames,
|
||||
height=gen_h,
|
||||
width=gen_w,
|
||||
fps=frame_rate,
|
||||
)
|
||||
),
|
||||
frame_rate,
|
||||
)
|
||||
with self.stage_2.model_context(video_tools=stage2_video_tools) as transformer:
|
||||
phase_latent = upscaled_video_latent
|
||||
for phase_idx, (tiling, sigmas_list, use_ic) in enumerate(
|
||||
zip(stage2_tilings, stage2_sigmas, stage2_use_ic_lora, strict=True)
|
||||
@@ -542,10 +557,10 @@ class HDRICLoraPipeline:
|
||||
"""
|
||||
# Cast to float32 so tiled-decode accumulation buffers and blending
|
||||
# masks run in full precision, avoiding bfloat16 seam artifacts.
|
||||
# Request float32 [0, 1] output — apply_hdr_decode_postprocess expects it.
|
||||
# apply_hdr_decode_postprocess expects float32 [0, 1].
|
||||
latent = latent.float()
|
||||
decoded = torch.cat(
|
||||
list(self.video_decoder(latent, tiling_config, generator, output_dtype=torch.float32)),
|
||||
[chunk.float() for chunk in self.video_decoder(latent, tiling_config, generator)],
|
||||
dim=0,
|
||||
)
|
||||
decoded = rearrange(decoded, "f h w c -> 1 c f h w")
|
||||
|
||||
@@ -2,20 +2,18 @@ import logging
|
||||
from collections.abc import Iterator
|
||||
|
||||
import torch
|
||||
from einops import rearrange
|
||||
from safetensors import safe_open
|
||||
|
||||
from ltx_core.components.noisers import GaussianNoiser
|
||||
from ltx_core.conditioning import (
|
||||
ConditioningItem,
|
||||
ConditioningItemAttentionStrengthWrapper,
|
||||
VideoConditionByReferenceLatent,
|
||||
)
|
||||
from ltx_core.conditioning import ConditioningItem
|
||||
from ltx_core.loader import LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.model.video_vae import TilingConfig, VideoEncoder, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, VideoLatentShape, VideoPixelShape
|
||||
from ltx_core.types import Audio, VideoPixelShape
|
||||
from ltx_pipelines.iclora_utils import (
|
||||
append_ic_lora_reference_video_conditionings,
|
||||
read_lora_reference_downscale_factor,
|
||||
)
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
VideoConditioningAction,
|
||||
@@ -108,7 +106,7 @@ class ICLoraPipeline:
|
||||
# so inference can resize reference videos to match training conditions.
|
||||
self.reference_downscale_factor = 1
|
||||
for lora in loras:
|
||||
scale = _read_lora_reference_downscale_factor(lora.path)
|
||||
scale = read_lora_reference_downscale_factor(lora.path)
|
||||
if scale != 1:
|
||||
if self.reference_downscale_factor not in (1, scale):
|
||||
raise ValueError(
|
||||
@@ -309,104 +307,26 @@ class ICLoraPipeline:
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
# Calculate scaled dimensions for reference video conditioning.
|
||||
# IC-LoRAs trained with downscaled reference videos expect the same ratio at inference.
|
||||
scale = self.reference_downscale_factor
|
||||
if scale != 1 and (height % scale != 0 or width % scale != 0):
|
||||
raise ValueError(
|
||||
f"Output dimensions ({height}x{width}) must be divisible by reference_downscale_factor ({scale})"
|
||||
)
|
||||
ref_height = height // scale
|
||||
ref_width = width // scale
|
||||
|
||||
for video_path, strength in video_conditioning:
|
||||
# Load video at scaled-down resolution (if scale > 1)
|
||||
frame_gen = decode_video_by_frame(path=video_path, frame_cap=num_frames, device=self.device)
|
||||
video = video_preprocess(frame_gen, ref_height, ref_width, self.dtype, self.device)
|
||||
encoded_video = video_encoder(video)
|
||||
reference_video_shape = VideoLatentShape.from_torch_shape(encoded_video.shape)
|
||||
|
||||
# Build attention_mask for ConditioningItemAttentionStrengthWrapper
|
||||
if conditioning_attention_mask is not None:
|
||||
# Downsample pixel-space mask to latent space, then scale by strength
|
||||
latent_mask = self._downsample_mask_to_latent(
|
||||
mask=conditioning_attention_mask,
|
||||
target_latent_shape=reference_video_shape,
|
||||
)
|
||||
attn_mask = latent_mask * conditioning_attention_strength
|
||||
elif conditioning_attention_strength < 1.0:
|
||||
# Use scalar strength only
|
||||
attn_mask = conditioning_attention_strength
|
||||
else:
|
||||
attn_mask = None
|
||||
|
||||
cond = VideoConditionByReferenceLatent(
|
||||
latent=encoded_video,
|
||||
downscale_factor=scale,
|
||||
strength=strength,
|
||||
)
|
||||
if attn_mask is not None:
|
||||
cond = ConditioningItemAttentionStrengthWrapper(cond, attention_mask=attn_mask)
|
||||
conditionings.append(cond)
|
||||
append_ic_lora_reference_video_conditionings(
|
||||
conditionings,
|
||||
video_conditioning,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
video_encoder=video_encoder,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
reference_downscale_factor=self.reference_downscale_factor,
|
||||
conditioning_attention_strength=conditioning_attention_strength,
|
||||
conditioning_attention_mask=conditioning_attention_mask,
|
||||
tiling_config=None,
|
||||
)
|
||||
|
||||
if video_conditioning:
|
||||
logging.info(f"[IC-LoRA] Added {len(video_conditioning)} video conditioning(s)")
|
||||
logging.info("[IC-LoRA] Added %d video conditioning(s)", len(video_conditioning))
|
||||
|
||||
return conditionings
|
||||
|
||||
@staticmethod
|
||||
def _downsample_mask_to_latent(
|
||||
mask: torch.Tensor,
|
||||
target_latent_shape: VideoLatentShape,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Downsample a pixel-space mask to latent space using VAE scale factors.
|
||||
Handles causal temporal downsampling: the first frame is kept separately
|
||||
(temporal scale factor = 1 for the first frame), while the remaining
|
||||
frames are downsampled by the VAE's temporal scale factor.
|
||||
Args:
|
||||
mask: Pixel-space mask of shape (B, 1, F_pixel, H_pixel, W_pixel).
|
||||
Values in [0, 1].
|
||||
target_latent_shape: Expected latent shape after VAE encoding.
|
||||
Used to determine the target (F_latent, H_latent, W_latent).
|
||||
Returns:
|
||||
Flattened latent-space mask of shape (B, F_lat * H_lat * W_lat),
|
||||
matching the patchifier's token ordering (f, h, w).
|
||||
"""
|
||||
b = mask.shape[0]
|
||||
f_lat = target_latent_shape.frames
|
||||
h_lat = target_latent_shape.height
|
||||
w_lat = target_latent_shape.width
|
||||
|
||||
# Step 1: Spatial downsampling (area interpolation per frame)
|
||||
f_pix = mask.shape[2]
|
||||
spatial_down = torch.nn.functional.interpolate(
|
||||
rearrange(mask, "b 1 f h w -> (b f) 1 h w"),
|
||||
size=(h_lat, w_lat),
|
||||
mode="area",
|
||||
)
|
||||
spatial_down = rearrange(spatial_down, "(b f) 1 h w -> b 1 f h w", b=b)
|
||||
|
||||
# Step 2: Causal temporal downsampling
|
||||
# First frame: kept as-is (causal VAE encodes first frame independently)
|
||||
first_frame = spatial_down[:, :, :1, :, :] # (B, 1, 1, H_lat, W_lat)
|
||||
|
||||
if f_pix > 1 and f_lat > 1:
|
||||
# Remaining frames: downsample by temporal factor via group-mean
|
||||
t = (f_pix - 1) // (f_lat - 1) # temporal downscale factor
|
||||
assert (f_pix - 1) % (f_lat - 1) == 0, (
|
||||
f"Pixel frames ({f_pix}) not compatible with latent frames ({f_lat}): "
|
||||
f"(f_pix - 1) must be divisible by (f_lat - 1)"
|
||||
)
|
||||
rest = rearrange(spatial_down[:, :, 1:, :, :], "b 1 (f t) h w -> b 1 f t h w", t=t)
|
||||
rest = rest.mean(dim=3) # (B, 1, F_lat-1, H_lat, W_lat)
|
||||
latent_mask = torch.cat([first_frame, rest], dim=2) # (B, 1, F_lat, H_lat, W_lat)
|
||||
else:
|
||||
latent_mask = first_frame
|
||||
|
||||
# Flatten to (B, F_lat * H_lat * W_lat) matching patchifier token order (f, h, w)
|
||||
return rearrange(latent_mask, "b 1 f h w -> b (f h w)")
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
@@ -523,26 +443,5 @@ def _load_mask_video(
|
||||
return mask.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _read_lora_reference_downscale_factor(lora_path: str) -> int:
|
||||
"""Read reference_downscale_factor from LoRA safetensors metadata.
|
||||
Some IC-LoRA models are trained with reference videos at lower resolution than
|
||||
the target output. This allows for more efficient training and can improve
|
||||
generalization. The downscale factor indicates the ratio between target and
|
||||
reference resolutions (e.g., factor=2 means reference is half the resolution).
|
||||
Args:
|
||||
lora_path: Path to the LoRA .safetensors file
|
||||
Returns:
|
||||
The reference downscale factor (1 if not specified in metadata, meaning
|
||||
reference and target have the same resolution)
|
||||
"""
|
||||
try:
|
||||
with safe_open(lora_path, framework="pt") as f:
|
||||
metadata = f.metadata() or {}
|
||||
return int(metadata.get("reference_downscale_factor", 1))
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to read metadata from LoRA file '{lora_path}': {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Shared IC-LoRA helpers: LoRA metadata, mask downsampling, reference-video conditioning.
|
||||
Used by ``ic_lora`` and ``lipdub`` (video reference path only). LipDub audio helpers live in ``lipdub.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import torch
|
||||
from einops import rearrange
|
||||
from safetensors import safe_open
|
||||
|
||||
from ltx_core.conditioning import (
|
||||
ConditioningItem,
|
||||
ConditioningItemAttentionStrengthWrapper,
|
||||
VideoConditionByReferenceLatent,
|
||||
)
|
||||
from ltx_core.model.video_vae import TilingConfig, VideoEncoder
|
||||
from ltx_core.types import VideoLatentShape
|
||||
from ltx_pipelines.utils.media_io import decode_video_by_frame, video_preprocess
|
||||
|
||||
|
||||
def read_lora_reference_downscale_factor(lora_path: str) -> int:
|
||||
"""Read ``reference_downscale_factor`` from LoRA safetensors metadata (default 1)."""
|
||||
try:
|
||||
with safe_open(lora_path, framework="pt") as f:
|
||||
metadata = f.metadata() or {}
|
||||
return int(metadata.get("reference_downscale_factor", 1))
|
||||
except Exception as e:
|
||||
logging.warning("Failed to read metadata from LoRA file '%s': %s", lora_path, e)
|
||||
return 1
|
||||
|
||||
|
||||
def downsample_mask_video_to_latent(
|
||||
mask: torch.Tensor,
|
||||
target_latent_shape: VideoLatentShape,
|
||||
) -> torch.Tensor:
|
||||
"""Downsample a pixel-space mask video to flattened latent token weights."""
|
||||
b = mask.shape[0]
|
||||
f_lat = target_latent_shape.frames
|
||||
h_lat = target_latent_shape.height
|
||||
w_lat = target_latent_shape.width
|
||||
|
||||
f_pix = mask.shape[2]
|
||||
spatial_down = torch.nn.functional.interpolate(
|
||||
rearrange(mask, "b 1 f h w -> (b f) 1 h w"),
|
||||
size=(h_lat, w_lat),
|
||||
mode="area",
|
||||
)
|
||||
spatial_down = rearrange(spatial_down, "(b f) 1 h w -> b 1 f h w", b=b)
|
||||
|
||||
first_frame = spatial_down[:, :, :1, :, :]
|
||||
|
||||
if f_pix > 1 and f_lat > 1:
|
||||
t = (f_pix - 1) // (f_lat - 1)
|
||||
assert (f_pix - 1) % (f_lat - 1) == 0, (
|
||||
f"Pixel frames ({f_pix}) not compatible with latent frames ({f_lat}): "
|
||||
f"(f_pix - 1) must be divisible by (f_lat - 1)"
|
||||
)
|
||||
rest = rearrange(spatial_down[:, :, 1:, :, :], "b 1 (f t) h w -> b 1 f t h w", t=t)
|
||||
rest = rest.mean(dim=3)
|
||||
latent_mask = torch.cat([first_frame, rest], dim=2)
|
||||
else:
|
||||
latent_mask = first_frame
|
||||
|
||||
return rearrange(latent_mask, "b 1 f h w -> b (f h w)")
|
||||
|
||||
|
||||
def append_ic_lora_reference_video_conditionings( # noqa: PLR0913
|
||||
conditionings: list[ConditioningItem],
|
||||
video_conditioning: list[tuple[str, float]],
|
||||
*,
|
||||
height: int,
|
||||
width: int,
|
||||
num_frames: int,
|
||||
video_encoder: VideoEncoder,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
reference_downscale_factor: int,
|
||||
conditioning_attention_strength: float,
|
||||
conditioning_attention_mask: torch.Tensor | None,
|
||||
tiling_config: TilingConfig | None = None,
|
||||
) -> None:
|
||||
"""Append :class:`VideoConditionByReferenceLatent` items for each reference path."""
|
||||
scale = reference_downscale_factor
|
||||
if scale != 1 and (height % scale != 0 or width % scale != 0):
|
||||
raise ValueError(
|
||||
f"Output dimensions ({height}x{width}) must be divisible by reference_downscale_factor ({scale})"
|
||||
)
|
||||
ref_height = height // scale
|
||||
ref_width = width // scale
|
||||
|
||||
for video_path, strength in video_conditioning:
|
||||
frame_gen = decode_video_by_frame(path=video_path, frame_cap=num_frames, device=device)
|
||||
video = video_preprocess(frame_gen, ref_height, ref_width, dtype, device)
|
||||
if tiling_config is not None:
|
||||
encoded_video = video_encoder.tiled_encode(video, tiling_config)
|
||||
else:
|
||||
encoded_video = video_encoder(video)
|
||||
reference_video_shape = VideoLatentShape.from_torch_shape(encoded_video.shape)
|
||||
|
||||
if conditioning_attention_mask is not None:
|
||||
latent_mask = downsample_mask_video_to_latent(
|
||||
mask=conditioning_attention_mask,
|
||||
target_latent_shape=reference_video_shape,
|
||||
)
|
||||
attn_mask = latent_mask * conditioning_attention_strength
|
||||
elif conditioning_attention_strength < 1.0:
|
||||
attn_mask = conditioning_attention_strength
|
||||
else:
|
||||
attn_mask = None
|
||||
|
||||
cond = VideoConditionByReferenceLatent(
|
||||
latent=encoded_video,
|
||||
downscale_factor=scale,
|
||||
strength=strength,
|
||||
)
|
||||
if attn_mask is not None:
|
||||
cond = ConditioningItemAttentionStrengthWrapper(cond, attention_mask=attn_mask)
|
||||
conditionings.append(cond)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Two-stage lip-dubbing pipeline with IC-LoRA and appended audio reference conditioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
|
||||
import torch
|
||||
|
||||
from ltx_core.components.noisers import GaussianNoiser
|
||||
from ltx_core.components.patchifiers import AudioPatchifier
|
||||
from ltx_core.conditioning import AudioConditionByReferenceLatent
|
||||
from ltx_core.loader import LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
|
||||
from ltx_core.model.video_vae import TilingConfig, VideoEncoder, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, AudioLatentShape, SpatioTemporalScaleFactors, VideoPixelShape
|
||||
from ltx_pipelines.iclora_utils import (
|
||||
append_ic_lora_reference_video_conditionings,
|
||||
read_lora_reference_downscale_factor,
|
||||
)
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
detect_checkpoint_path,
|
||||
lipdub_arg_parser,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioConditioner,
|
||||
AudioDecoder,
|
||||
DiffusionStage,
|
||||
ImageConditioner,
|
||||
PromptEncoder,
|
||||
VideoDecoder,
|
||||
VideoUpsampler,
|
||||
)
|
||||
from ltx_pipelines.utils.constants import DISTILLED_SIGMAS, STAGE_2_DISTILLED_SIGMAS, detect_params
|
||||
from ltx_pipelines.utils.denoisers import SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import assert_resolution, combined_image_conditionings, get_device
|
||||
from ltx_pipelines.utils.media_io import decode_audio_from_file, encode_video, get_videostream_metadata
|
||||
from ltx_pipelines.utils.types import ModalitySpec, OffloadMode
|
||||
|
||||
|
||||
def _snap_frames_to_8k1(frames: int) -> int:
|
||||
"""Round ``frames`` down to the nearest ``8k+1`` (the model's required frame count)."""
|
||||
time_scale = SpatioTemporalScaleFactors.default().time
|
||||
return ((frames - 1) // time_scale) * time_scale + 1
|
||||
|
||||
|
||||
class LipDubPipeline:
|
||||
"""Two-stage lip-dubbing with IC-LoRA video reference and appended audio reference tokens."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
distilled_checkpoint_path: str,
|
||||
spatial_upsampler_path: str,
|
||||
gemma_root: str,
|
||||
ic_lora: LoraPathStrengthAndSDOps,
|
||||
device: torch.device | None = None,
|
||||
quantization: QuantizationPolicy | None = None,
|
||||
registry: Registry | None = None,
|
||||
torch_compile: bool = False,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
) -> None:
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
self.ic_lora = ic_lora
|
||||
loras = (ic_lora,)
|
||||
|
||||
self.prompt_encoder = PromptEncoder(
|
||||
distilled_checkpoint_path,
|
||||
gemma_root,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_conditioner = AudioConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
)
|
||||
self.stage = DiffusionStage(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
loras=loras,
|
||||
quantization=quantization,
|
||||
registry=registry,
|
||||
torch_compile=torch_compile,
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
distilled_checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
)
|
||||
self.video_decoder = VideoDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.reference_downscale_factor = read_lora_reference_downscale_factor(ic_lora.path)
|
||||
|
||||
def _create_stage_conditionings(
|
||||
self,
|
||||
images: list[ImageConditioningInput],
|
||||
reference_video_path: str,
|
||||
reference_strength: float,
|
||||
height: int,
|
||||
width: int,
|
||||
num_frames: int,
|
||||
video_encoder: VideoEncoder,
|
||||
encode_tiling: TilingConfig | None,
|
||||
) -> list:
|
||||
conditionings = combined_image_conditionings(
|
||||
images=images,
|
||||
height=height,
|
||||
width=width,
|
||||
video_encoder=video_encoder,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
)
|
||||
append_ic_lora_reference_video_conditionings(
|
||||
conditionings,
|
||||
[(reference_video_path, reference_strength)],
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
video_encoder=video_encoder,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
reference_downscale_factor=self.reference_downscale_factor,
|
||||
conditioning_attention_strength=1.0,
|
||||
conditioning_attention_mask=None,
|
||||
tiling_config=encode_tiling,
|
||||
)
|
||||
return conditionings
|
||||
|
||||
def _encode_reference_audio_vae_latent(self, video_path: str) -> torch.Tensor:
|
||||
audio = decode_audio_from_file(video_path, self.device)
|
||||
if audio is None:
|
||||
msg = f"No audio stream found in {video_path}"
|
||||
raise ValueError(msg)
|
||||
return self.audio_conditioner(lambda enc: vae_encode_audio(audio, enc, None))
|
||||
|
||||
@torch.inference_mode()
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
prompt: str,
|
||||
seed: int,
|
||||
height: int,
|
||||
width: int,
|
||||
images: list[ImageConditioningInput],
|
||||
reference_video_path: str,
|
||||
reference_strength: float = 1.0,
|
||||
enhance_prompt: bool = False,
|
||||
tiling_config: TilingConfig | None = None,
|
||||
stage_1_sigmas: torch.Tensor = DISTILLED_SIGMAS,
|
||||
stage_2_sigmas: torch.Tensor = STAGE_2_DISTILLED_SIGMAS,
|
||||
) -> tuple[Iterator[torch.Tensor], Audio]:
|
||||
assert_resolution(height=height, width=width, is_two_stage=True)
|
||||
|
||||
meta = get_videostream_metadata(reference_video_path)
|
||||
num_frames = _snap_frames_to_8k1(meta.frames)
|
||||
frame_rate = float(meta.fps)
|
||||
|
||||
generator = torch.Generator(device=self.device).manual_seed(seed)
|
||||
noiser = GaussianNoiser(generator=generator)
|
||||
|
||||
(ctx_p,) = self.prompt_encoder(
|
||||
[prompt],
|
||||
enhance_first_prompt=enhance_prompt,
|
||||
enhance_prompt_image=images[0][0] if len(images) > 0 else None,
|
||||
enhance_prompt_seed=seed,
|
||||
)
|
||||
video_context, audio_context = ctx_p.video_encoding, ctx_p.audio_encoding
|
||||
|
||||
stage_1_output_shape = VideoPixelShape(
|
||||
batch=1,
|
||||
frames=num_frames,
|
||||
width=width // 2,
|
||||
height=height // 2,
|
||||
fps=frame_rate,
|
||||
)
|
||||
encode_tiling = TilingConfig.default()
|
||||
|
||||
def build_image_conditionings(output_shape: VideoPixelShape) -> list:
|
||||
return self.image_conditioner(
|
||||
lambda enc: self._create_stage_conditionings(
|
||||
images=images,
|
||||
reference_video_path=reference_video_path,
|
||||
reference_strength=reference_strength,
|
||||
height=output_shape.height,
|
||||
width=output_shape.width,
|
||||
num_frames=num_frames,
|
||||
video_encoder=enc,
|
||||
encode_tiling=encode_tiling,
|
||||
)
|
||||
)
|
||||
|
||||
def build_audio_ref_conditioning(audio_latent: torch.Tensor) -> AudioConditionByReferenceLatent:
|
||||
ref_patch, ref_pos = patchify_lipdub_audio_reference_latent(
|
||||
audio_latent,
|
||||
negative_positions=True,
|
||||
device=self.device,
|
||||
)
|
||||
return AudioConditionByReferenceLatent(ref_patch, ref_pos, strength=1.0)
|
||||
|
||||
stage_1_conditionings = build_image_conditionings(stage_1_output_shape)
|
||||
|
||||
ref_vae = self._encode_reference_audio_vae_latent(reference_video_path)
|
||||
audio_conditionings = [build_audio_ref_conditioning(ref_vae)]
|
||||
|
||||
stage_1_sigmas_tensor = stage_1_sigmas.to(dtype=torch.float32, device=self.device)
|
||||
video_state, audio_state = self.stage(
|
||||
denoiser=SimpleDenoiser(video_context, audio_context),
|
||||
sigmas=stage_1_sigmas_tensor,
|
||||
noiser=noiser,
|
||||
width=stage_1_output_shape.width,
|
||||
height=stage_1_output_shape.height,
|
||||
frames=num_frames,
|
||||
fps=frame_rate,
|
||||
video=ModalitySpec(
|
||||
context=video_context,
|
||||
conditionings=stage_1_conditionings,
|
||||
),
|
||||
audio=ModalitySpec(
|
||||
context=audio_context,
|
||||
conditionings=audio_conditionings,
|
||||
),
|
||||
)
|
||||
|
||||
s1_audio_latent = audio_state.latent.clone()
|
||||
|
||||
upscaled_video_latent = self.upsampler(video_state.latent[:1])
|
||||
stage_2_sigmas_tensor = stage_2_sigmas.to(dtype=torch.float32, device=self.device)
|
||||
stage_2_output_shape = VideoPixelShape(batch=1, frames=num_frames, width=width, height=height, fps=frame_rate)
|
||||
stage_2_conditionings = build_image_conditionings(stage_2_output_shape)
|
||||
|
||||
stage_2_audio_conditionings = [build_audio_ref_conditioning(s1_audio_latent)]
|
||||
|
||||
video_state, _audio_unused = self.stage(
|
||||
denoiser=SimpleDenoiser(video_context, audio_context),
|
||||
sigmas=stage_2_sigmas_tensor,
|
||||
noiser=noiser,
|
||||
width=width,
|
||||
height=height,
|
||||
frames=num_frames,
|
||||
fps=frame_rate,
|
||||
video=ModalitySpec(
|
||||
context=video_context,
|
||||
conditionings=stage_2_conditionings,
|
||||
noise_scale=stage_2_sigmas_tensor[0].item(),
|
||||
initial_latent=upscaled_video_latent,
|
||||
),
|
||||
audio=ModalitySpec(
|
||||
context=audio_context,
|
||||
conditionings=stage_2_audio_conditionings,
|
||||
frozen=True,
|
||||
noise_scale=0.0,
|
||||
initial_latent=s1_audio_latent,
|
||||
),
|
||||
)
|
||||
|
||||
decoded_video = self.video_decoder(video_state.latent, tiling_config, generator)
|
||||
decoded_audio = self.audio_decoder(s1_audio_latent)
|
||||
return decoded_video, decoded_audio
|
||||
|
||||
|
||||
def patchify_lipdub_audio_reference_latent(
|
||||
vae_latents: torch.Tensor,
|
||||
*,
|
||||
negative_positions: bool,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Patchify audio VAE latents and build RoPE positions (optional negative shift for reference)."""
|
||||
patchifier = AudioPatchifier(patch_size=1)
|
||||
patchified = patchifier.patchify(vae_latents)
|
||||
b, c, _t, mel_bins = vae_latents.shape
|
||||
seq_len = patchified.shape[1]
|
||||
latent_coords = patchifier.get_patch_grid_bounds(
|
||||
output_shape=AudioLatentShape(batch=b, channels=c, frames=seq_len, mel_bins=mel_bins),
|
||||
device=device,
|
||||
)
|
||||
positions = latent_coords.to(dtype=torch.float32)
|
||||
if negative_positions:
|
||||
aud_dur = positions[:, :, -1, 1].max().item()
|
||||
positions = positions - aud_dur - 0.04
|
||||
return patchified, positions
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path(distilled=True)
|
||||
params = detect_params(checkpoint_path)
|
||||
parser = lipdub_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.lora or len(args.lora) != 1:
|
||||
raise ValueError("LipDub requires exactly one --lora (the lip-dub IC-LoRA).")
|
||||
|
||||
pipeline = LipDubPipeline(
|
||||
distilled_checkpoint_path=args.distilled_checkpoint_path,
|
||||
spatial_upsampler_path=args.spatial_upsampler_path,
|
||||
gemma_root=args.gemma_root,
|
||||
ic_lora=args.lora[0],
|
||||
quantization=args.quantization,
|
||||
torch_compile=args.compile,
|
||||
offload_mode=args.offload_mode,
|
||||
)
|
||||
tiling_config = TilingConfig.default()
|
||||
src = get_videostream_metadata(args.reference_video)
|
||||
video_chunks_number = get_video_chunks_number(_snap_frames_to_8k1(src.frames), tiling_config)
|
||||
video, audio = pipeline(
|
||||
prompt=args.prompt,
|
||||
seed=args.seed,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
images=[],
|
||||
reference_video_path=args.reference_video,
|
||||
reference_strength=args.reference_strength,
|
||||
tiling_config=tiling_config,
|
||||
enhance_prompt=args.enhance_prompt,
|
||||
)
|
||||
encode_video(
|
||||
video=video,
|
||||
fps=int(src.fps),
|
||||
audio=audio,
|
||||
output_path=args.output_path,
|
||||
video_chunks_number=video_chunks_number,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -307,7 +307,7 @@ def main() -> None:
|
||||
gemma_root=args.gemma_root,
|
||||
loras=tuple(args.lora) if args.lora else (),
|
||||
quantization=args.quantization,
|
||||
distilled=args.distilled,
|
||||
distilled=True,
|
||||
torch_compile=args.compile,
|
||||
offload_mode=args.offload_mode,
|
||||
)
|
||||
|
||||
@@ -16,15 +16,17 @@ from ltx_pipelines.utils.helpers import (
|
||||
image_conditionings_by_adding_guiding_latent,
|
||||
)
|
||||
from ltx_pipelines.utils.samplers import (
|
||||
euler_cfg_pp_denoising_loop,
|
||||
euler_denoising_loop,
|
||||
gradient_estimating_euler_denoising_loop,
|
||||
res2s_audio_video_denoising_loop,
|
||||
)
|
||||
from ltx_pipelines.utils.types import Denoiser, ModalitySpec
|
||||
from ltx_pipelines.utils.types import DenoisedLatentResult, Denoiser, ModalitySpec
|
||||
|
||||
__all__ = [
|
||||
"AudioConditioner",
|
||||
"AudioDecoder",
|
||||
"DenoisedLatentResult",
|
||||
"Denoiser",
|
||||
"DiffusionStage",
|
||||
"FactoryGuidedDenoiser",
|
||||
@@ -38,6 +40,7 @@ __all__ = [
|
||||
"assert_resolution",
|
||||
"cleanup_memory",
|
||||
"combined_image_conditionings",
|
||||
"euler_cfg_pp_denoising_loop",
|
||||
"euler_denoising_loop",
|
||||
"get_device",
|
||||
"gradient_estimating_euler_denoising_loop",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -115,35 +116,34 @@ def resolve_path(path: str) -> str:
|
||||
QUANTIZATION_POLICIES = ("fp8-cast", "fp8-scaled-mm")
|
||||
|
||||
|
||||
class QuantizationAction(argparse.Action):
|
||||
def __call__(
|
||||
self,
|
||||
parser: argparse.ArgumentParser, # noqa: ARG002
|
||||
namespace: argparse.Namespace,
|
||||
values: list[str],
|
||||
option_string: str | None = None,
|
||||
) -> None:
|
||||
if len(values) > 2:
|
||||
msg = (
|
||||
f"{option_string} accepts at most 2 arguments (POLICY and optional AMAX_PATH), got {len(values)} values"
|
||||
def _resolve_quantization(namespace: argparse.Namespace) -> None:
|
||||
# Resolution is deferred until after parse_args because fp8-scaled-mm needs the
|
||||
# checkpoint path, which isn't on the namespace when the --quantization argument
|
||||
# is parsed.
|
||||
name = getattr(namespace, "quantization", None)
|
||||
if name is None or isinstance(name, QuantizationPolicy):
|
||||
return
|
||||
if name == "fp8-cast":
|
||||
namespace.quantization = QuantizationPolicy.fp8_cast()
|
||||
return
|
||||
if name == "fp8-scaled-mm":
|
||||
ckpt = getattr(namespace, "checkpoint_path", None) or getattr(namespace, "distilled_checkpoint_path", None)
|
||||
if ckpt is None:
|
||||
raise SystemExit(
|
||||
"--quantization fp8-scaled-mm requires --checkpoint-path (or --distilled-checkpoint-path)."
|
||||
)
|
||||
raise argparse.ArgumentError(self, msg)
|
||||
namespace.quantization = QuantizationPolicy.fp8_scaled_mm(ckpt)
|
||||
|
||||
policy_name = values[0]
|
||||
if policy_name not in QUANTIZATION_POLICIES:
|
||||
msg = f"Unknown quantization policy '{policy_name}'. Choose from: {', '.join(QUANTIZATION_POLICIES)}"
|
||||
raise argparse.ArgumentError(self, msg)
|
||||
|
||||
if policy_name == "fp8-cast":
|
||||
if len(values) > 1:
|
||||
msg = f"{option_string} fp8-cast does not accept additional arguments"
|
||||
raise argparse.ArgumentError(self, msg)
|
||||
policy = QuantizationPolicy.fp8_cast()
|
||||
elif policy_name == "fp8-scaled-mm":
|
||||
amax_path = resolve_path(values[1]) if len(values) > 1 else None
|
||||
policy = QuantizationPolicy.fp8_scaled_mm(amax_path)
|
||||
|
||||
setattr(namespace, self.dest, policy)
|
||||
class _PipelineArgumentParser(argparse.ArgumentParser):
|
||||
def parse_args( # type: ignore[override]
|
||||
self,
|
||||
args: Sequence[str] | None = None,
|
||||
namespace: argparse.Namespace | None = None,
|
||||
) -> argparse.Namespace:
|
||||
ns = super().parse_args(args, namespace)
|
||||
_resolve_quantization(ns)
|
||||
return ns
|
||||
|
||||
|
||||
def detect_checkpoint_path(distilled: bool = False) -> str:
|
||||
@@ -159,7 +159,7 @@ def basic_arg_parser(
|
||||
params: PipelineParams = LTX_2_3_PARAMS,
|
||||
distilled: bool = False,
|
||||
) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser = _PipelineArgumentParser()
|
||||
if distilled:
|
||||
parser.add_argument(
|
||||
"--distilled-checkpoint-path",
|
||||
@@ -264,16 +264,14 @@ def basic_arg_parser(
|
||||
|
||||
parser.add_argument(
|
||||
"--quantization",
|
||||
dest="quantization",
|
||||
action=QuantizationAction,
|
||||
nargs="+",
|
||||
metavar=("POLICY", "AMAX_PATH"),
|
||||
choices=QUANTIZATION_POLICIES,
|
||||
default=None,
|
||||
help=(
|
||||
f"Quantization policy: {', '.join(QUANTIZATION_POLICIES)}. "
|
||||
"fp8-cast uses FP8 casting with upcasting during inference. "
|
||||
"fp8-scaled-mm uses FP8 scaled matrix multiplication (optionally provide amax calibration file path). "
|
||||
"Example: --quantization fp8-cast or --quantization fp8-scaled-mm /path/to/amax.json"
|
||||
"fp8-scaled-mm uses FP8 scaled matrix multiplication; the layer set is auto-discovered "
|
||||
"from the checkpoint's .weight_scale tensors. "
|
||||
"Example: --quantization fp8-cast or --quantization fp8-scaled-mm"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -348,6 +346,53 @@ def video_editing_arg_parser(
|
||||
return parser
|
||||
|
||||
|
||||
def lipdub_arg_parser(
|
||||
params: PipelineParams = LTX_2_3_PARAMS,
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Argument parser for the lip-dub pipeline.
|
||||
Frame count and frame rate are derived from the reference video at runtime (the frame count
|
||||
is silently snapped down to the nearest 8k+1), so this parser intentionally omits
|
||||
--num-frames, --frame-rate, and --image. Distilled checkpoint only.
|
||||
"""
|
||||
parser = basic_arg_parser(params=params, distilled=True)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=params.stage_2_height,
|
||||
help=(
|
||||
f"Height of the generated video in pixels, should be divisible by 64 (default: {params.stage_2_height})."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=params.stage_2_width,
|
||||
help=f"Width of the generated video in pixels, should be divisible by 64 (default: {params.stage_2_width}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spatial-upsampler-path",
|
||||
type=resolve_path,
|
||||
required=True,
|
||||
help=(
|
||||
"Path to the spatial upsampler model used to increase the resolution "
|
||||
"of the generated video in the latent space."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-video",
|
||||
type=resolve_path,
|
||||
required=True,
|
||||
help="Reference video file (video + audio track used for IC-LoRA and audio identity).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-strength",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Strength for IC-LoRA video reference conditioning (default: 1.0).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def default_1_stage_arg_parser(params: PipelineParams = LTX_2_3_PARAMS) -> argparse.ArgumentParser:
|
||||
video_guider = params.video_guider_params
|
||||
audio_guider = params.audio_guider_params
|
||||
|
||||
@@ -21,7 +21,8 @@ from ltx_core.components.noisers import Noiser
|
||||
from ltx_core.components.patchifiers import AudioPatchifier, VideoLatentPatchifier
|
||||
from ltx_core.components.protocols import DiffusionStepProtocol
|
||||
from ltx_core.loader import SDOps
|
||||
from ltx_core.loader.primitives import LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.module_ops import ModuleOps
|
||||
from ltx_core.loader.primitives import BuilderProtocol, LoraPathStrengthAndSDOps, ModelBuilderProtocol
|
||||
from ltx_core.loader.registry import DummyRegistry, Registry
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
from ltx_core.model.audio_vae import (
|
||||
@@ -37,12 +38,14 @@ from ltx_core.model.audio_vae import (
|
||||
)
|
||||
from ltx_core.model.transformer import (
|
||||
LTXV_MODEL_COMFY_RENAMING_MAP,
|
||||
LTXModel,
|
||||
LTXModelConfigurator,
|
||||
X0Model,
|
||||
)
|
||||
from ltx_core.model.transformer.compiling import COMPILE_TRANSFORMER, modify_sd_ops_for_compilation
|
||||
from ltx_core.model.upsampler import LatentUpsamplerConfigurator, upsample_video
|
||||
from ltx_core.model.video_vae import (
|
||||
MEMORY_EFFICIENT_DECODE,
|
||||
VAE_DECODER_COMFY_KEYS_FILTER,
|
||||
VAE_ENCODER_COMFY_KEYS_FILTER,
|
||||
TilingConfig,
|
||||
@@ -59,10 +62,11 @@ from ltx_core.text_encoders.gemma import (
|
||||
GemmaTextEncoderConfigurator,
|
||||
module_ops_from_gemma_root,
|
||||
)
|
||||
from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessorOutput
|
||||
from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessor, EmbeddingsProcessorOutput
|
||||
from ltx_core.tools import AudioLatentTools, LatentTools, VideoLatentTools
|
||||
from ltx_core.types import Audio, AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
|
||||
from ltx_core.utils import find_matching_file
|
||||
from ltx_pipelines.multigpu.delegating_builder import DelegatingBuilder
|
||||
from ltx_pipelines.utils.gpu_model import gpu_model
|
||||
from ltx_pipelines.utils.helpers import (
|
||||
cleanup_memory,
|
||||
@@ -83,6 +87,20 @@ _M = TypeVar("_M", bound=torch.nn.Module)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _chain_quantization(
|
||||
sd_ops: SDOps,
|
||||
module_ops: tuple[ModuleOps, ...],
|
||||
quantization: QuantizationPolicy,
|
||||
) -> tuple[SDOps, tuple[ModuleOps, ...]]:
|
||||
chained_sd_ops = sd_ops
|
||||
if quantization.sd_ops is not None:
|
||||
chained_sd_ops = SDOps(
|
||||
name=f"sd_ops_chain_{sd_ops.name}+{quantization.sd_ops.name}",
|
||||
mapping=(*sd_ops.mapping, *quantization.sd_ops.mapping),
|
||||
)
|
||||
return chained_sd_ops, (*module_ops, *quantization.module_ops)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _streaming_model(
|
||||
builder: StreamingModelBuilder,
|
||||
@@ -154,16 +172,43 @@ class DiffusionStage:
|
||||
registry: Registry | None = None,
|
||||
torch_compile: bool = False,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
transformer_builder: ModelBuilderProtocol[LTXModel] | DelegatingBuilder[LTXModel] | None = None,
|
||||
) -> None:
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._quantization = quantization
|
||||
self._torch_compile = torch_compile
|
||||
self._offload_mode = offload_mode
|
||||
if transformer_builder is not None:
|
||||
self._transformer_builder = transformer_builder
|
||||
else:
|
||||
self._transformer_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=LTXModelConfigurator,
|
||||
model_sd_ops=LTXV_MODEL_COMFY_RENAMING_MAP,
|
||||
loras=tuple(loras),
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
|
||||
if offload_mode != OffloadMode.NONE:
|
||||
if torch_compile:
|
||||
raise ValueError("torch.compile is not supported with layer streaming")
|
||||
streaming_sd_ops: SDOps = LTXV_MODEL_COMFY_RENAMING_MAP
|
||||
streaming_module_ops: tuple[ModuleOps, ...] = ()
|
||||
if quantization is not None:
|
||||
raise ValueError("quantization is not supported with layer streaming")
|
||||
if quantization.kind != QuantizationPolicy.Kind.FP8_CAST:
|
||||
raise ValueError(
|
||||
f"Layer streaming supports only QuantizationPolicy.fp8_cast(); "
|
||||
f"got kind={quantization.kind!r} which produces heterogeneous block layouts."
|
||||
)
|
||||
streaming_sd_ops, streaming_module_ops = _chain_quantization(
|
||||
streaming_sd_ops, streaming_module_ops, quantization
|
||||
)
|
||||
self._streaming_builder = StreamingModelBuilder(
|
||||
model_class_configurator=LTXModelConfigurator,
|
||||
model_path=checkpoint_path,
|
||||
model_sd_ops=LTXV_MODEL_COMFY_RENAMING_MAP,
|
||||
model_sd_ops=streaming_sd_ops,
|
||||
module_ops=streaming_module_ops,
|
||||
loras=tuple(loras),
|
||||
registry=registry or DummyRegistry(),
|
||||
blocks_attr="velocity_model.transformer_blocks",
|
||||
@@ -172,19 +217,6 @@ class DiffusionStage:
|
||||
model_wrapper=lambda m: X0Model(m).eval(),
|
||||
)
|
||||
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._quantization = quantization
|
||||
self._torch_compile = torch_compile
|
||||
self._offload_mode = offload_mode
|
||||
self._transformer_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=LTXModelConfigurator,
|
||||
model_sd_ops=LTXV_MODEL_COMFY_RENAMING_MAP,
|
||||
loras=tuple(loras),
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
|
||||
def _build_transformer(self, *, device: torch.device | None = None, **kwargs: object) -> X0Model:
|
||||
target = device or self._device
|
||||
sd_ops = self._transformer_builder.model_sd_ops
|
||||
@@ -198,18 +230,12 @@ class DiffusionStage:
|
||||
LoraPathStrengthAndSDOps(
|
||||
lora.path,
|
||||
lora.strength,
|
||||
modify_sd_ops_for_compilation(
|
||||
lora.sd_ops if lora.sd_ops is not None else SDOps(name="identity"), number_of_layers
|
||||
),
|
||||
modify_sd_ops_for_compilation(lora.sd_ops, number_of_layers),
|
||||
)
|
||||
for lora in loras
|
||||
)
|
||||
if self._quantization is not None:
|
||||
module_ops = (*module_ops, *self._quantization.module_ops)
|
||||
sd_ops = SDOps(
|
||||
name=f"sd_ops_chain_{sd_ops.name}+{self._quantization.sd_ops.name}",
|
||||
mapping=(*sd_ops.mapping, *self._quantization.sd_ops.mapping),
|
||||
)
|
||||
sd_ops, module_ops = _chain_quantization(sd_ops, module_ops, self._quantization)
|
||||
|
||||
builder = self._transformer_builder.with_module_ops(module_ops).with_sd_ops(sd_ops).with_loras(loras)
|
||||
return X0Model(builder.build(device=target, **kwargs)).to(target).eval()
|
||||
@@ -359,31 +385,40 @@ class PromptEncoder:
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
text_encoder_builder: BuilderProtocol | None = None,
|
||||
) -> None:
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._offload_mode = offload_mode
|
||||
|
||||
module_ops = module_ops_from_gemma_root(gemma_root)
|
||||
model_folder = find_matching_file(gemma_root, "model*.safetensors").parent
|
||||
weight_paths = [str(p) for p in model_folder.rglob("*.safetensors")]
|
||||
|
||||
self._text_encoder_builder = Builder(
|
||||
model_path=tuple(weight_paths),
|
||||
model_class_configurator=GemmaTextEncoderConfigurator,
|
||||
model_sd_ops=GEMMA_LLM_KEY_OPS,
|
||||
module_ops=(GEMMA_MODEL_OPS, *module_ops),
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
self._streaming_text_encoder_builder = StreamingModelBuilder(
|
||||
model_path=tuple(weight_paths),
|
||||
model_class_configurator=GemmaTextEncoderConfigurator,
|
||||
model_sd_ops=GEMMA_LLM_KEY_OPS,
|
||||
module_ops=(GEMMA_MODEL_OPS, *module_ops),
|
||||
registry=registry or DummyRegistry(),
|
||||
blocks_attr="model.model.language_model.layers",
|
||||
blocks_prefix="model.model.language_model.layers",
|
||||
)
|
||||
if text_encoder_builder is not None:
|
||||
if offload_mode != OffloadMode.NONE:
|
||||
raise ValueError(
|
||||
"text_encoder_builder cannot be used with offload_mode != OffloadMode.NONE "
|
||||
"because no streaming text encoder builder is available."
|
||||
)
|
||||
self._text_encoder_builder = text_encoder_builder
|
||||
self._streaming_text_encoder_builder = None
|
||||
else:
|
||||
module_ops = module_ops_from_gemma_root(gemma_root)
|
||||
model_folder = find_matching_file(gemma_root, "model*.safetensors").parent
|
||||
weight_paths = [str(p) for p in model_folder.rglob("*.safetensors")]
|
||||
self._text_encoder_builder = Builder(
|
||||
model_path=tuple(weight_paths),
|
||||
model_class_configurator=GemmaTextEncoderConfigurator,
|
||||
model_sd_ops=GEMMA_LLM_KEY_OPS,
|
||||
module_ops=(GEMMA_MODEL_OPS, *module_ops),
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
self._streaming_text_encoder_builder = StreamingModelBuilder(
|
||||
model_path=tuple(weight_paths),
|
||||
model_class_configurator=GemmaTextEncoderConfigurator,
|
||||
model_sd_ops=GEMMA_LLM_KEY_OPS,
|
||||
module_ops=(GEMMA_MODEL_OPS, *module_ops),
|
||||
registry=registry or DummyRegistry(),
|
||||
blocks_attr="model.model.language_model.layers",
|
||||
blocks_prefix="model.model.language_model.layers",
|
||||
)
|
||||
self._embeddings_processor_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=EmbeddingsProcessorConfigurator,
|
||||
@@ -391,10 +426,18 @@ class PromptEncoder:
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
|
||||
def _build_text_encoder(self) -> torch.nn.Module:
|
||||
"""Build the Gemma text encoder (non-streaming path)."""
|
||||
return self._text_encoder_builder.build(device=self._device, dtype=self._dtype).eval()
|
||||
|
||||
def _build_embeddings_processor(self) -> EmbeddingsProcessor:
|
||||
"""Build the embeddings processor on the target device."""
|
||||
return self._embeddings_processor_builder.build(device=self._device, dtype=self._dtype).to(self._device).eval()
|
||||
|
||||
def _text_encoder_ctx(self) -> AbstractContextManager:
|
||||
if self._offload_mode != OffloadMode.NONE:
|
||||
return _streaming_model(self._streaming_text_encoder_builder, self._offload_mode, self._device, self._dtype)
|
||||
return gpu_model(self._text_encoder_builder.build(device=self._device, dtype=self._dtype).eval())
|
||||
return gpu_model(self._build_text_encoder())
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -413,9 +456,7 @@ class PromptEncoder:
|
||||
)
|
||||
raw_outputs = [text_encoder.encode(p) for p in prompts]
|
||||
|
||||
with gpu_model(
|
||||
self._embeddings_processor_builder.build(device=self._device, dtype=self._dtype).to(self._device).eval()
|
||||
) as embeddings_processor:
|
||||
with gpu_model(self._build_embeddings_processor()) as embeddings_processor:
|
||||
return [embeddings_processor.process_hidden_states(hs, mask) for hs, mask in raw_outputs]
|
||||
|
||||
|
||||
@@ -513,32 +554,31 @@ class VideoDecoder:
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
memory_efficient: bool = True,
|
||||
decoder_builder: BuilderProtocol | None = None,
|
||||
) -> None:
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._decoder_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=VideoDecoderConfigurator,
|
||||
model_sd_ops=VAE_DECODER_COMFY_KEYS_FILTER,
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
if decoder_builder is not None:
|
||||
self._decoder_builder = decoder_builder
|
||||
else:
|
||||
self._decoder_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=VideoDecoderConfigurator,
|
||||
model_sd_ops=VAE_DECODER_COMFY_KEYS_FILTER,
|
||||
registry=registry or DummyRegistry(),
|
||||
module_ops=(MEMORY_EFFICIENT_DECODE,) if memory_efficient else (),
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
latent: torch.Tensor,
|
||||
tiling_config: TilingConfig | None = None,
|
||||
generator: torch.Generator | None = None,
|
||||
*,
|
||||
output_dtype: torch.dtype = torch.uint8,
|
||||
) -> Iterator[torch.Tensor]:
|
||||
"""Decode *latent* to pixel-space video chunks. Decoder freed after exhaustion.
|
||||
Args:
|
||||
output_dtype: Target dtype for output tensors. ``torch.uint8``
|
||||
(default) maps to ``[0, 255]``. Any floating dtype returns
|
||||
``[0, 1]`` cast to that dtype.
|
||||
"""
|
||||
"""Decode *latent* to pixel-space video chunks. Decoder freed after exhaustion."""
|
||||
decoder = self._decoder_builder.build(device=self._device, dtype=self._dtype).to(self._device).eval()
|
||||
return _cleanup_iter(decoder.decode_video(latent, tiling_config, generator, output_dtype=output_dtype), decoder)
|
||||
return _cleanup_iter(decoder.decode_video(latent, tiling_config, generator), decoder)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Color space conversion utilities for video encoding.
|
||||
Provides GPU-accelerated RGB to YUV420 conversion that runs between the
|
||||
VAE decoder (which yields float RGB chunks) and ``encode_video``, bypassing
|
||||
pyav's CPU-side libswscale conversion. The ``FrameConverter`` also carries
|
||||
the codec metadata (pixel format, colour space, colour range) that
|
||||
``encode_video`` needs to tag the output stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
class ColorSpace(enum.Enum):
|
||||
"""YUV color space standard."""
|
||||
|
||||
BT_709 = "bt709"
|
||||
BT_2020_NCL = "bt2020ncl"
|
||||
|
||||
@property
|
||||
def av_colorspace(self) -> int:
|
||||
"""FFmpeg ``AVCOL_SPC_*`` constant for ``codec_context.colorspace``."""
|
||||
return _AV_COLORSPACE[self]
|
||||
|
||||
|
||||
class ColorRange(enum.Enum):
|
||||
"""YUV color range."""
|
||||
|
||||
MPEG = "mpeg"
|
||||
JPEG = "jpeg"
|
||||
|
||||
@property
|
||||
def av_color_range(self) -> int:
|
||||
"""FFmpeg ``AVCOL_RANGE_*`` constant for ``codec_context.color_range``."""
|
||||
return _AV_COLOR_RANGE[self]
|
||||
|
||||
|
||||
class PixelFormat(enum.Enum):
|
||||
"""Pixel format for video frames."""
|
||||
|
||||
RGB24 = "rgb24"
|
||||
YUV420P = "yuv420p"
|
||||
|
||||
@property
|
||||
def av_format(self) -> str:
|
||||
"""PyAV format string for ``VideoFrame.from_ndarray``."""
|
||||
return self.value
|
||||
|
||||
|
||||
_AV_COLORSPACE = {
|
||||
ColorSpace.BT_709: 1, # AVCOL_SPC_BT709
|
||||
ColorSpace.BT_2020_NCL: 9, # AVCOL_SPC_BT2020_NCL
|
||||
}
|
||||
|
||||
_AV_COLOR_RANGE = {
|
||||
ColorRange.MPEG: 1, # AVCOL_RANGE_MPEG (limited)
|
||||
ColorRange.JPEG: 2, # AVCOL_RANGE_JPEG (full)
|
||||
}
|
||||
|
||||
# BT.709 RGB->YUV matrix (row-major: each row produces one of Y, U, V)
|
||||
_BT709_MATRIX = torch.tensor(
|
||||
[
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[-0.1146, -0.3854, 0.5],
|
||||
[0.5, -0.4542, -0.0458],
|
||||
],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
# BT.2020 NCL RGB->YUV matrix
|
||||
_KR_2020 = 0.2627
|
||||
_KG_2020 = 0.6780
|
||||
_KB_2020 = 0.0593
|
||||
_BT2020_MATRIX = torch.tensor(
|
||||
[
|
||||
[_KR_2020, _KG_2020, _KB_2020],
|
||||
[-_KR_2020 / 1.8814, -_KG_2020 / 1.8814, 0.5],
|
||||
[0.5, -_KG_2020 / 1.4746, -_KB_2020 / 1.4746],
|
||||
],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
_COLOR_SPACE_MATRICES = {
|
||||
ColorSpace.BT_709: _BT709_MATRIX,
|
||||
ColorSpace.BT_2020_NCL: _BT2020_MATRIX,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameConverter:
|
||||
"""Converts ``[*, C, H, W]`` float ``[0, 1]`` frames to uint8.
|
||||
Carries encoding metadata so ``encode_video`` can derive pixel format,
|
||||
color space, and color range from the converter itself.
|
||||
The ``fn_`` callable **may mutate its input** (PyTorch trailing-underscore
|
||||
convention). Callers that need to keep the original ``frames`` afterwards
|
||||
must pass ``frames.clone()``. Inside ``encode_video``'s per-chunk
|
||||
generator each chunk is consumed once, so direct passthrough is safe.
|
||||
"""
|
||||
|
||||
pixel_format: PixelFormat
|
||||
fn_: Callable[[torch.Tensor], torch.Tensor] = field(repr=False)
|
||||
color_space: ColorSpace | None = None
|
||||
color_range: ColorRange | None = None
|
||||
|
||||
def __call__(self, frames: torch.Tensor) -> torch.Tensor:
|
||||
return self.fn_(frames)
|
||||
|
||||
|
||||
def rgb_to_yuv(image: torch.Tensor, color_space: ColorSpace) -> torch.Tensor:
|
||||
"""Convert an RGB image to YUV.
|
||||
The image data is assumed to be in the range of ``[0, 1]``.
|
||||
Uses a single matrix multiply for better memory locality.
|
||||
Args:
|
||||
image: RGB image with shape ``(*, 3, H, W)``.
|
||||
color_space: Color space standard for the conversion matrix.
|
||||
Returns:
|
||||
YUV image with shape ``(*, 3, H, W)``.
|
||||
"""
|
||||
if len(image.shape) < 3 or image.shape[-3] != 3:
|
||||
raise ValueError(f"Input size must have a shape of (*, 3, H, W). Got {image.shape}")
|
||||
|
||||
mat = _COLOR_SPACE_MATRICES[color_space].to(device=image.device, dtype=image.dtype)
|
||||
# [*, 3, H, W] -> [*, H, W, 3] @ [3, 3]^T -> [*, H, W, 3] -> [*, 3, H, W]
|
||||
pixels = image.movedim(-3, -1) # [*, H, W, 3]
|
||||
yuv = pixels @ mat.T # [*, H, W, 3]
|
||||
return yuv.movedim(-1, -3) # [*, 3, H, W]
|
||||
|
||||
|
||||
def apply_color_range_(y: torch.Tensor, uv: torch.Tensor, color_range: ColorRange) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Scale Y and UV planes to the specified color range, in-place.
|
||||
Args:
|
||||
y: Luma plane in ``[0, 1]``.
|
||||
uv: Chroma planes centered at 0.
|
||||
color_range: Target color range.
|
||||
Returns:
|
||||
Scaled ``(Y, UV)`` tensors (modified in-place).
|
||||
"""
|
||||
if color_range == ColorRange.MPEG:
|
||||
y.mul_(219).add_(16)
|
||||
uv.mul_(224).add_(128)
|
||||
elif color_range == ColorRange.JPEG:
|
||||
y.mul_(255)
|
||||
uv.add_(0.5).mul_(255)
|
||||
else:
|
||||
raise ValueError(f"Unsupported color range: {color_range}")
|
||||
return y, uv
|
||||
|
||||
|
||||
def rgb_to_yuv420(
|
||||
image: torch.Tensor, color_space: ColorSpace, color_range: ColorRange
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Convert an RGB image to YUV 4:2:0 with chroma subsampling.
|
||||
Chroma is subsampled by averaging 2x2 pixel blocks (chroma siting
|
||||
``(128, 128)``).
|
||||
Args:
|
||||
image: RGB image with shape ``(*, 3, H, W)`` in ``[0, 1]``.
|
||||
H and W must be divisible by 2.
|
||||
color_space: Color space standard.
|
||||
color_range: Color range for the output.
|
||||
Returns:
|
||||
``(Y, UV)`` where Y has shape ``(*, 1, H, W)`` and UV has shape
|
||||
``(*, 2, H//2, W//2)``.
|
||||
"""
|
||||
if len(image.shape) < 3 or image.shape[-3] != 3:
|
||||
raise ValueError(f"Input size must have a shape of (*, 3, H, W). Got {image.shape}")
|
||||
if image.shape[-2] % 2 != 0 or image.shape[-1] % 2 != 0:
|
||||
raise ValueError(f"Input H and W must be divisible by 2. Got {image.shape}")
|
||||
|
||||
yuv = rgb_to_yuv(image, color_space)
|
||||
y = yuv[..., :1, :, :]
|
||||
# Subsample chroma: average 2x2 blocks via avg_pool2d (contiguous, fused kernel)
|
||||
uv_full = yuv[..., 1:3, :, :].contiguous()
|
||||
# Flatten leading dims for avg_pool2d which expects [N, C, H, W]
|
||||
lead = uv_full.shape[:-3]
|
||||
uv_flat = uv_full.reshape(-1, 2, uv_full.shape[-2], uv_full.shape[-1])
|
||||
uv = torch.nn.functional.avg_pool2d(uv_flat, kernel_size=2, stride=2)
|
||||
uv = uv.reshape(*lead, 2, uv.shape[-2], uv.shape[-1])
|
||||
|
||||
return apply_color_range_(y, uv, color_range)
|
||||
|
||||
|
||||
def pack_i420(y: torch.Tensor, uv: torch.Tensor) -> torch.Tensor:
|
||||
"""Pack Y and UV planes into I420 layout for pyav.
|
||||
I420 packs the three planes into a single 2D array of height ``H * 3 // 2``
|
||||
and width ``W``. The Y plane occupies the first ``H`` rows. The UV tensor
|
||||
``(*, 2, H//2, W//2)`` is reshaped to ``(*, H//2, W)`` -- U rows packed
|
||||
two-by-two followed by V rows packed two-by-two -- and appended below.
|
||||
Args:
|
||||
y: Luma with shape ``(*, 1, H, W)``.
|
||||
uv: Chroma with shape ``(*, 2, H//2, W//2)``.
|
||||
Returns:
|
||||
Packed tensor with shape ``(*, H*3//2, W)`` uint8.
|
||||
"""
|
||||
y_plane = y[..., 0, :, :] # [*, H, W]
|
||||
uv_packed = uv.reshape(*uv.shape[:-3], uv.shape[-2], uv.shape[-1] * 2) # [*, H//2, W]
|
||||
packed = torch.cat([y_plane, uv_packed], dim=-2) # [*, H*3//2, W]
|
||||
return packed.clamp_(0, 255).to(torch.uint8)
|
||||
|
||||
|
||||
def _rgb_uint8_fn_(frames: torch.Tensor) -> torch.Tensor:
|
||||
"""In-place: mutates ``frames`` via ``clamp_`` + ``mul_``, returns a uint8 view."""
|
||||
return frames.clamp_(0.0, 1.0).mul_(255.0).to(torch.uint8).movedim(-3, -1)
|
||||
|
||||
|
||||
rgb_uint8_converter_ = FrameConverter(pixel_format=PixelFormat.RGB24, fn_=_rgb_uint8_fn_)
|
||||
"""``(*, 3, H, W)`` float ``[0, 1]`` to ``(*, H, W, 3)`` uint8. Mutates input."""
|
||||
|
||||
|
||||
def _yuv420p_bt709_fn_(frames: torch.Tensor) -> torch.Tensor:
|
||||
y, uv = rgb_to_yuv420(frames, ColorSpace.BT_709, ColorRange.MPEG)
|
||||
return pack_i420(y, uv)
|
||||
|
||||
|
||||
yuv420p_bt709_converter_ = FrameConverter(
|
||||
pixel_format=PixelFormat.YUV420P,
|
||||
fn_=_yuv420p_bt709_fn_,
|
||||
color_space=ColorSpace.BT_709,
|
||||
color_range=ColorRange.MPEG,
|
||||
)
|
||||
"""``(*, 3, H, W)`` float ``[0, 1]`` to ``(*, H*3//2, W)`` uint8 YUV420p BT.709 MPEG."""
|
||||
@@ -20,6 +20,7 @@ from ltx_core.guidance.perturbations import (
|
||||
from ltx_core.model.transformer import X0Model
|
||||
from ltx_core.types import LatentState
|
||||
from ltx_pipelines.utils.helpers import modality_from_latent_state
|
||||
from ltx_pipelines.utils.types import DenoisedLatentResult
|
||||
|
||||
_POSITIVE_ONLY_GUIDER = MultiModalGuider(
|
||||
params=MultiModalGuiderParams(cfg_scale=1.0, stg_scale=0.0, modality_scale=1.0),
|
||||
@@ -53,7 +54,7 @@ def _repeat_state(state: LatentState, n: int) -> LatentState:
|
||||
)
|
||||
|
||||
|
||||
def _guided_denoise( # noqa: PLR0913
|
||||
def _guided_denoise( # noqa: PLR0913,PLR0915
|
||||
transformer: X0Model,
|
||||
video_state: LatentState | None,
|
||||
audio_state: LatentState | None,
|
||||
@@ -66,7 +67,8 @@ def _guided_denoise( # noqa: PLR0913
|
||||
last_denoised_video: torch.Tensor | None,
|
||||
last_denoised_audio: torch.Tensor | None,
|
||||
step_index: int,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
force_uncond_pass: bool = False,
|
||||
) -> tuple[DenoisedLatentResult | None, DenoisedLatentResult | None]:
|
||||
"""Core guided denoising — batches all guidance passes into one transformer call.
|
||||
Collects per-pass contexts first, then builds a single batched Modality
|
||||
per present modality via :func:`modality_from_latent_state`. When wrapped
|
||||
@@ -80,7 +82,9 @@ def _guided_denoise( # noqa: PLR0913
|
||||
a_skip = audio_guider.should_skip_step(step_index)
|
||||
|
||||
if v_skip and a_skip:
|
||||
return last_denoised_video, last_denoised_audio
|
||||
video_result = DenoisedLatentResult.result_or_none(denoised=last_denoised_video)
|
||||
audio_result = DenoisedLatentResult.result_or_none(denoised=last_denoised_audio)
|
||||
return video_result, audio_result
|
||||
|
||||
if video_state is not None and v_context is None:
|
||||
raise ValueError("v_context is required when video_state is provided")
|
||||
@@ -91,10 +95,12 @@ def _guided_denoise( # noqa: PLR0913
|
||||
_pass = tuple[str, torch.Tensor | None, torch.Tensor | None, PerturbationConfig]
|
||||
passes: list[_pass] = [("cond", v_context, a_context, PerturbationConfig.empty())]
|
||||
|
||||
if video_guider.do_unconditional_generation() or audio_guider.do_unconditional_generation():
|
||||
if video_guider.do_unconditional_generation() and video_guider.negative_context is None:
|
||||
v_needs_neg = video_guider.do_unconditional_generation() or (force_uncond_pass and video_state is not None)
|
||||
a_needs_neg = audio_guider.do_unconditional_generation() or (force_uncond_pass and audio_state is not None)
|
||||
if v_needs_neg or a_needs_neg:
|
||||
if v_needs_neg and video_guider.negative_context is None:
|
||||
raise ValueError("Negative context is required for unconditioned denoising")
|
||||
if audio_guider.do_unconditional_generation() and audio_guider.negative_context is None:
|
||||
if a_needs_neg and audio_guider.negative_context is None:
|
||||
raise ValueError("Negative context is required for unconditioned denoising")
|
||||
v_neg = video_guider.negative_context if video_guider.negative_context is not None else v_context
|
||||
a_neg = audio_guider.negative_context if audio_guider.negative_context is not None else a_context
|
||||
@@ -172,7 +178,14 @@ def _guided_denoise( # noqa: PLR0913
|
||||
|
||||
denoised_video = last_denoised_video if v_skip else video_guider.calculate(cond_v, uncond_v, ptb_v, mod_v)
|
||||
denoised_audio = last_denoised_audio if a_skip else audio_guider.calculate(cond_a, uncond_a, ptb_a, mod_a)
|
||||
return denoised_video, denoised_audio
|
||||
return (
|
||||
DenoisedLatentResult.result_or_none(
|
||||
denoised=denoised_video, uncond=uncond_v, cond=cond_v, ptb=ptb_v, mod=mod_v
|
||||
),
|
||||
DenoisedLatentResult.result_or_none(
|
||||
denoised=denoised_audio, uncond=uncond_a, cond=cond_a, ptb=ptb_a, mod=mod_a
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SimpleDenoiser:
|
||||
@@ -195,11 +208,15 @@ class SimpleDenoiser:
|
||||
audio_state: LatentState | None,
|
||||
sigmas: torch.Tensor,
|
||||
step_index: int,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
) -> tuple[DenoisedLatentResult | None, DenoisedLatentResult | None]:
|
||||
sigma = sigmas[step_index]
|
||||
pos_video = modality_from_latent_state(video_state, self.v_context, sigma) if video_state is not None else None
|
||||
pos_audio = modality_from_latent_state(audio_state, self.a_context, sigma) if audio_state is not None else None
|
||||
return transformer(video=pos_video, audio=pos_audio, perturbations=None)
|
||||
denoised_video, denoised_audio = transformer(video=pos_video, audio=pos_audio, perturbations=None)
|
||||
return (
|
||||
DenoisedLatentResult.result_or_none(denoised=denoised_video),
|
||||
DenoisedLatentResult.result_or_none(denoised=denoised_audio),
|
||||
)
|
||||
|
||||
|
||||
class GuidedDenoiser:
|
||||
@@ -214,11 +231,13 @@ class GuidedDenoiser:
|
||||
a_context: torch.Tensor | None,
|
||||
video_guider: MultiModalGuider | None = None,
|
||||
audio_guider: MultiModalGuider | None = None,
|
||||
force_uncond_pass: bool = False,
|
||||
) -> None:
|
||||
self.v_context = v_context
|
||||
self.a_context = a_context
|
||||
self.video_guider = video_guider
|
||||
self.audio_guider = audio_guider
|
||||
self.force_uncond_pass = force_uncond_pass
|
||||
self._last_denoised_video: torch.Tensor | None = None
|
||||
self._last_denoised_audio: torch.Tensor | None = None
|
||||
|
||||
@@ -229,8 +248,8 @@ class GuidedDenoiser:
|
||||
audio_state: LatentState | None,
|
||||
sigmas: torch.Tensor,
|
||||
step_index: int,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
denoised_video, denoised_audio = _guided_denoise(
|
||||
) -> tuple[DenoisedLatentResult | None, DenoisedLatentResult | None]:
|
||||
guided_denoise_result_v, guided_denoise_result_a = _guided_denoise(
|
||||
transformer=transformer,
|
||||
video_state=video_state,
|
||||
audio_state=audio_state,
|
||||
@@ -242,10 +261,11 @@ class GuidedDenoiser:
|
||||
last_denoised_video=self._last_denoised_video,
|
||||
last_denoised_audio=self._last_denoised_audio,
|
||||
step_index=step_index,
|
||||
force_uncond_pass=self.force_uncond_pass,
|
||||
)
|
||||
self._last_denoised_video = denoised_video
|
||||
self._last_denoised_audio = denoised_audio
|
||||
return denoised_video, denoised_audio
|
||||
self._last_denoised_video = guided_denoise_result_v.denoised
|
||||
self._last_denoised_audio = guided_denoise_result_a.denoised
|
||||
return guided_denoise_result_v, guided_denoise_result_a
|
||||
|
||||
|
||||
class FactoryGuidedDenoiser:
|
||||
@@ -257,11 +277,13 @@ class FactoryGuidedDenoiser:
|
||||
a_context: torch.Tensor | None,
|
||||
video_guider_factory: MultiModalGuiderFactory | None = None,
|
||||
audio_guider_factory: MultiModalGuiderFactory | None = None,
|
||||
force_uncond_pass: bool = False,
|
||||
) -> None:
|
||||
self.v_context = v_context
|
||||
self.a_context = a_context
|
||||
self.video_guider_factory = video_guider_factory
|
||||
self.audio_guider_factory = audio_guider_factory
|
||||
self.force_uncond_pass = force_uncond_pass
|
||||
self._last_denoised_video: torch.Tensor | None = None
|
||||
self._last_denoised_audio: torch.Tensor | None = None
|
||||
self._sigma_vals_cached: list[float] | None = None
|
||||
@@ -273,7 +295,7 @@ class FactoryGuidedDenoiser:
|
||||
audio_state: LatentState | None,
|
||||
sigmas: torch.Tensor,
|
||||
step_index: int,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
) -> tuple[DenoisedLatentResult | None, DenoisedLatentResult | None]:
|
||||
if self._sigma_vals_cached is None:
|
||||
self._sigma_vals_cached = sigmas.detach().cpu().tolist()
|
||||
sigma_val = self._sigma_vals_cached[step_index]
|
||||
@@ -287,7 +309,7 @@ class FactoryGuidedDenoiser:
|
||||
else None
|
||||
)
|
||||
|
||||
denoised_video, denoised_audio = _guided_denoise(
|
||||
guided_denoise_result_v, guided_denoise_result_a = _guided_denoise(
|
||||
transformer=transformer,
|
||||
video_state=video_state,
|
||||
audio_state=audio_state,
|
||||
@@ -299,7 +321,8 @@ class FactoryGuidedDenoiser:
|
||||
last_denoised_video=self._last_denoised_video,
|
||||
last_denoised_audio=self._last_denoised_audio,
|
||||
step_index=step_index,
|
||||
force_uncond_pass=self.force_uncond_pass,
|
||||
)
|
||||
self._last_denoised_video = denoised_video
|
||||
self._last_denoised_audio = denoised_audio
|
||||
return denoised_video, denoised_audio
|
||||
self._last_denoised_video = guided_denoise_result_v.denoised
|
||||
self._last_denoised_audio = guided_denoise_result_a.denoised
|
||||
return guided_denoise_result_v, guided_denoise_result_a
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import enum
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
from collections.abc import Generator, Iterator
|
||||
from fractions import Fraction
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
@@ -17,6 +19,7 @@ from tqdm import tqdm
|
||||
|
||||
from ltx_core.hdr import LogC3
|
||||
from ltx_core.types import Audio, VideoPixelShape
|
||||
from ltx_pipelines.utils.color_conversion import FrameConverter, PixelFormat, yuv420p_bt709_converter_
|
||||
from ltx_pipelines.utils.constants import DEFAULT_IMAGE_CRF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -86,8 +89,8 @@ def resize_and_center_crop(tensor: torch.Tensor, height: int, width: int) -> tor
|
||||
return tensor
|
||||
|
||||
|
||||
def normalize_latent(latent: torch.Tensor, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
||||
return (latent / 127.5 - 1.0).to(device=device, dtype=dtype)
|
||||
def normalize_images(images: torch.Tensor, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
||||
return (images / 127.5 - 1.0).to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
def to_vae_range(x: torch.Tensor) -> torch.Tensor:
|
||||
@@ -116,7 +119,7 @@ def load_image_and_preprocess(
|
||||
image = preprocess(image=image, crf=crf)
|
||||
image = torch.tensor(image, dtype=torch.float32, device=device)
|
||||
image = resize_and_center_crop(image, height, width)
|
||||
image = normalize_latent(image, device, dtype)
|
||||
image = normalize_images(image, device, dtype)
|
||||
return image
|
||||
|
||||
|
||||
@@ -137,11 +140,13 @@ def video_preprocess(
|
||||
Returns:
|
||||
Tensor of shape (1, C, F, height, width) with values in [-1, 1].
|
||||
"""
|
||||
result = None
|
||||
result: torch.Tensor | None = None
|
||||
for f in frames:
|
||||
frame = resize_and_center_crop(f.to(torch.float32), height, width)
|
||||
frame = normalize_latent(frame, device, dtype)
|
||||
frame = normalize_images(frame, device, dtype)
|
||||
result = frame if result is None else torch.cat([result, frame], dim=2)
|
||||
if result is None:
|
||||
raise ValueError("video_preprocess received an empty frame generator; no frames were decoded from the source.")
|
||||
return result
|
||||
|
||||
|
||||
@@ -325,47 +330,120 @@ def encode_video(
|
||||
audio: Audio | None,
|
||||
output_path: str,
|
||||
video_chunks_number: int,
|
||||
frame_converter: FrameConverter = yuv420p_bt709_converter_,
|
||||
crf: int = 19,
|
||||
preset: str = "veryfast",
|
||||
thread_count: int = 0,
|
||||
) -> None:
|
||||
if isinstance(video, torch.Tensor):
|
||||
video = iter([video])
|
||||
|
||||
first_chunk = next(video)
|
||||
def convert(chunk: torch.Tensor) -> torch.Tensor:
|
||||
return frame_converter(chunk.movedim(-1, -3))
|
||||
|
||||
_, height, width, _ = first_chunk.shape
|
||||
first_chunk = convert(next(video))
|
||||
|
||||
if frame_converter.pixel_format == PixelFormat.RGB24:
|
||||
height, width = first_chunk.shape[-3], first_chunk.shape[-2]
|
||||
else:
|
||||
height = first_chunk.shape[-2] * 2 // 3
|
||||
width = first_chunk.shape[-1]
|
||||
|
||||
container = av.open(output_path, mode="w")
|
||||
stream = container.add_stream("libx264", rate=int(fps))
|
||||
stream.width = width
|
||||
stream.height = height
|
||||
stream.pix_fmt = "yuv420p"
|
||||
success = False
|
||||
try:
|
||||
stream = container.add_stream("libx264", rate=int(fps), options={"crf": str(crf), "preset": preset})
|
||||
stream.width = width
|
||||
stream.height = height
|
||||
stream.pix_fmt = "yuv420p"
|
||||
stream.codec_context.thread_count = thread_count
|
||||
stream.codec_context.thread_type = "FRAME"
|
||||
if frame_converter.color_space is not None:
|
||||
stream.codec_context.colorspace = frame_converter.color_space.av_colorspace
|
||||
if frame_converter.color_range is not None:
|
||||
stream.codec_context.color_range = frame_converter.color_range.av_color_range
|
||||
|
||||
if audio is not None:
|
||||
audio_stream = _prepare_audio_stream(container, audio.sampling_rate)
|
||||
if audio is not None:
|
||||
audio_stream = _prepare_audio_stream(container, audio.sampling_rate)
|
||||
|
||||
def all_tiles(
|
||||
first_chunk: torch.Tensor, tiles_generator: Generator[tuple[torch.Tensor, int], None, None]
|
||||
) -> Generator[tuple[torch.Tensor, int], None, None]:
|
||||
yield first_chunk
|
||||
yield from tiles_generator
|
||||
av_format = frame_converter.pixel_format.av_format
|
||||
|
||||
for video_chunk in tqdm(all_tiles(first_chunk, video), total=video_chunks_number):
|
||||
video_chunk_cpu = video_chunk.to("cpu").numpy()
|
||||
for frame_array in video_chunk_cpu:
|
||||
frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24")
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
def cpu_chunks() -> Generator[np.ndarray, None, None]:
|
||||
yield first_chunk.to("cpu").numpy()
|
||||
for chunk in video:
|
||||
yield convert(chunk).to("cpu").numpy()
|
||||
|
||||
# Flush encoder
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
_encode_chunks_threaded(
|
||||
container=container,
|
||||
stream=stream,
|
||||
av_format=av_format,
|
||||
chunks=cpu_chunks(),
|
||||
progress_total=video_chunks_number,
|
||||
)
|
||||
|
||||
if audio is not None:
|
||||
_write_audio(container, audio_stream, audio)
|
||||
|
||||
container.close()
|
||||
if audio is not None:
|
||||
_write_audio(container, audio_stream, audio)
|
||||
success = True
|
||||
finally:
|
||||
container.close()
|
||||
if not success:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
logger.info(f"Video saved to {output_path}")
|
||||
|
||||
|
||||
def _encode_chunks_threaded(
|
||||
container: av.container.Container,
|
||||
stream: av.video.stream.VideoStream,
|
||||
av_format: str,
|
||||
chunks: Iterator[np.ndarray],
|
||||
progress_total: int,
|
||||
) -> None:
|
||||
"""Run libx264 frame.encode + container.mux on a background thread while
|
||||
the caller produces numpy chunks on the current thread. The 1-slot queue
|
||||
lets the producer get one chunk ahead (so the next VAE/gather chunk
|
||||
overlaps with libx264 encoding the previous chunk) without buffering more
|
||||
than one chunk in CPU memory.
|
||||
"""
|
||||
chunk_queue: Queue[np.ndarray | None] = Queue(maxsize=1)
|
||||
encoder_error: list[BaseException] = []
|
||||
|
||||
def encoder_worker() -> None:
|
||||
error: BaseException | None = None
|
||||
while True:
|
||||
arr = chunk_queue.get()
|
||||
if arr is None:
|
||||
break
|
||||
if error is not None:
|
||||
continue
|
||||
try:
|
||||
for frame_array in arr:
|
||||
frame = av.VideoFrame.from_ndarray(frame_array, format=av_format)
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
except Exception as e:
|
||||
error = e
|
||||
if error is None:
|
||||
try:
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
except Exception as e:
|
||||
error = e
|
||||
if error is not None:
|
||||
encoder_error.append(error)
|
||||
|
||||
encoder_thread = threading.Thread(target=encoder_worker, name="h264-encoder")
|
||||
encoder_thread.start()
|
||||
try:
|
||||
for arr in tqdm(chunks, total=progress_total):
|
||||
chunk_queue.put(arr)
|
||||
finally:
|
||||
chunk_queue.put(None)
|
||||
encoder_thread.join()
|
||||
|
||||
if encoder_error:
|
||||
raise encoder_error[0]
|
||||
|
||||
|
||||
_INT_FORMAT_MAX: dict[str, float] = {
|
||||
"u8": 128.0,
|
||||
"u8p": 128.0,
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Callable
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
from ltx_core.components.diffusion_steps import Res2sDiffusionStep
|
||||
from ltx_core.components.diffusion_steps import EulerCfgPpDiffusionStep, Res2sDiffusionStep
|
||||
from ltx_core.components.protocols import DiffusionStepProtocol
|
||||
from ltx_core.model.transformer import X0Model
|
||||
from ltx_core.utils import to_denoised, to_velocity
|
||||
@@ -60,13 +60,15 @@ def euler_denoising_loop(
|
||||
denoiser:
|
||||
A callable implementing :class:`Denoiser`. It is invoked as
|
||||
``denoiser(transformer, video_state, audio_state, sigmas, step_index)``
|
||||
and must return ``(denoised_video, denoised_audio)``.
|
||||
and must return a :class:`~ltx_pipelines.utils.types.DenoisedLatentResult`.
|
||||
### Returns
|
||||
tuple[LatentState | None, LatentState | None]
|
||||
Final ``(video_state, audio_state)`` after the denoising loop.
|
||||
"""
|
||||
for step_idx, _ in enumerate(tqdm(sigmas[:-1])):
|
||||
denoised_video, denoised_audio = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
video_result, audio_result = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
denoised_video = video_result.denoised if video_result is not None else None
|
||||
denoised_audio = audio_result.denoised if audio_result is not None else None
|
||||
|
||||
video_state = _step_state(video_state, denoised_video, stepper, sigmas, step_idx)
|
||||
audio_state = _step_state(audio_state, denoised_audio, stepper, sigmas, step_idx)
|
||||
@@ -110,7 +112,9 @@ def gradient_estimating_euler_denoising_loop(
|
||||
return current_velocity, denoised_sample
|
||||
|
||||
for step_idx, _ in enumerate(tqdm(sigmas[:-1])):
|
||||
denoised_video, denoised_audio = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
video_result, audio_result = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
denoised_video = video_result.denoised if video_result is not None else None
|
||||
denoised_audio = audio_result.denoised if audio_result is not None else None
|
||||
|
||||
if video_state is not None and denoised_video is not None:
|
||||
denoised_video = post_process_latent(denoised_video, video_state.denoise_mask, video_state.clean_latent)
|
||||
@@ -143,6 +147,11 @@ def gradient_estimating_euler_denoising_loop(
|
||||
return (video_state, audio_state)
|
||||
|
||||
|
||||
def _get_plain_noise(x: torch.Tensor, generator: torch.Generator) -> torch.Tensor:
|
||||
"""Draw standard Gaussian noise matching the shape, dtype, and device of ``x``."""
|
||||
return torch.randn(x.shape, generator=generator, dtype=x.dtype, device=x.device)
|
||||
|
||||
|
||||
def _channelwise_normalize(x: torch.Tensor) -> torch.Tensor:
|
||||
return x.sub_(x.mean(dim=(-2, -1), keepdim=True)).div_(x.std(dim=(-2, -1), keepdim=True))
|
||||
|
||||
@@ -278,7 +287,9 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
# ====================================================================
|
||||
# STAGE 1: Evaluate at current point
|
||||
# ====================================================================
|
||||
denoised_video_1, denoised_audio_1 = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
video_result, audio_result = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
denoised_video_1 = video_result.denoised if video_result is not None else None
|
||||
denoised_audio_1 = audio_result.denoised if audio_result is not None else None
|
||||
if video_state is not None and denoised_video_1 is not None:
|
||||
denoised_video_1 = post_process_latent(denoised_video_1, video_state.denoise_mask, video_state.clean_latent)
|
||||
if audio_state is not None and denoised_audio_1 is not None:
|
||||
@@ -355,13 +366,15 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
else None
|
||||
)
|
||||
|
||||
denoised_video_2, denoised_audio_2 = denoiser(
|
||||
video_result_2, audio_result_2 = denoiser(
|
||||
transformer,
|
||||
video_state=mid_video_state,
|
||||
audio_state=mid_audio_state,
|
||||
sigmas=torch.stack([sub_sigma]).to(sigmas.device),
|
||||
step_index=0,
|
||||
)
|
||||
denoised_video_2 = video_result_2.denoised if video_result_2 is not None else None
|
||||
denoised_audio_2 = audio_result_2.denoised if audio_result_2 is not None else None
|
||||
if video_state is not None and denoised_video_2 is not None:
|
||||
denoised_video_2 = post_process_latent(denoised_video_2, video_state.denoise_mask, video_state.clean_latent)
|
||||
if audio_state is not None and denoised_audio_2 is not None:
|
||||
@@ -410,7 +423,9 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
|
||||
# Final step if we need to fully remove the noise
|
||||
if sigmas[-1] == 0:
|
||||
denoised_video_1, denoised_audio_1 = denoiser(transformer, video_state, audio_state, sigmas, n_full_steps)
|
||||
video_result_final, audio_result_final = denoiser(transformer, video_state, audio_state, sigmas, n_full_steps)
|
||||
denoised_video_1 = video_result_final.denoised if video_result_final is not None else None
|
||||
denoised_audio_1 = audio_result_final.denoised if audio_result_final is not None else None
|
||||
if video_state is not None and denoised_video_1 is not None:
|
||||
denoised_video_1 = post_process_latent(denoised_video_1, video_state.denoise_mask, video_state.clean_latent)
|
||||
video_state = replace(video_state, latent=denoised_video_1.to(model_dtype))
|
||||
@@ -419,3 +434,121 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
audio_state = replace(audio_state, latent=denoised_audio_1.to(model_dtype))
|
||||
|
||||
return video_state, audio_state
|
||||
|
||||
|
||||
def euler_cfg_pp_denoising_loop(
|
||||
sigmas: torch.Tensor,
|
||||
video_state: LatentState | None,
|
||||
audio_state: LatentState | None,
|
||||
stepper: EulerCfgPpDiffusionStep,
|
||||
transformer: X0Model,
|
||||
denoiser: Denoiser,
|
||||
noise_seed: int = -1,
|
||||
new_noise_fn: Callable[[torch.Tensor, torch.Generator], torch.Tensor] = _get_plain_noise,
|
||||
model_dtype: torch.dtype = torch.bfloat16,
|
||||
) -> tuple[LatentState | None, LatentState | None]:
|
||||
"""
|
||||
Joint audio-video denoising loop using the CFG++ corrected Euler sampler.
|
||||
Applies the CFG++ update rule at each step: the ODE derivative is computed
|
||||
from the unconditioned denoised prediction rather than the standard velocity,
|
||||
and an ancestral DDIM noise injection is applied in the rescaled sigma space.
|
||||
Requires a guided denoiser whose :class:`~ltx_pipelines.utils.types.DenoisedLatentResult`
|
||||
carries ``uncond`` tensors (i.e. CFG must be enabled).
|
||||
Either ``video_state`` or ``audio_state`` may be ``None`` for absent modalities.
|
||||
When both are present, noise is drawn from the same seeded generator (video
|
||||
first, audio second) to produce a consistent random sequence.
|
||||
### Parameters
|
||||
sigmas:
|
||||
1-D tensor of noise levels defining the sampling schedule.
|
||||
video_state:
|
||||
Current video :class:`~ltx_core.types.LatentState`, or ``None``.
|
||||
audio_state:
|
||||
Current audio :class:`~ltx_core.types.LatentState`, or ``None``.
|
||||
stepper:
|
||||
:class:`~ltx_core.components.diffusion_steps.EulerCfgPpDiffusionStep`
|
||||
instance carrying ``eta`` and ``s_noise`` parameters.
|
||||
transformer:
|
||||
The diffusion model passed to the denoiser at each step.
|
||||
denoiser:
|
||||
Callable implementing :class:`~ltx_pipelines.utils.types.Denoiser`.
|
||||
noise_seed:
|
||||
Integer seed for the noise generator. Default ``-1``.
|
||||
new_noise_fn:
|
||||
``(latent, generator) -> noise`` callable. Defaults to plain
|
||||
``torch.randn`` (no channel-wise normalization). Pass
|
||||
:func:`_get_new_noise` for the normalized variant used in res2s.
|
||||
model_dtype:
|
||||
Dtype for latent state updates. Default ``bfloat16``.
|
||||
### Returns
|
||||
tuple[LatentState | None, LatentState | None]
|
||||
Final ``(video_state, audio_state)`` after the denoising loop.
|
||||
"""
|
||||
if not isinstance(stepper, EulerCfgPpDiffusionStep):
|
||||
raise ValueError(f"stepper must be an instance of EulerCfgPpDiffusionStep, got {type(stepper).__name__}")
|
||||
|
||||
present_state = video_state or audio_state
|
||||
if present_state is None:
|
||||
raise ValueError("At least one of video_state or audio_state must be provided")
|
||||
|
||||
generator = torch.Generator(device=present_state.latent.device).manual_seed(noise_seed)
|
||||
draw_noise = stepper.eta > 0 and stepper.s_noise > 0
|
||||
|
||||
for step_idx, _ in enumerate(tqdm(sigmas[:-1])):
|
||||
video_result, audio_result = denoiser(transformer, video_state, audio_state, sigmas, step_idx)
|
||||
denoised_video = video_result.denoised if video_result is not None else None
|
||||
denoised_audio = audio_result.denoised if audio_result is not None else None
|
||||
uncond_video = video_result.uncond if video_result is not None else None
|
||||
uncond_audio = audio_result.uncond if audio_result is not None else None
|
||||
|
||||
if video_state is not None and not isinstance(uncond_video, torch.Tensor):
|
||||
raise ValueError(
|
||||
"euler_cfg_pp_denoising_loop requires video DenoisedLatentResult.uncond to be a tensor. "
|
||||
"Use GuidedDenoiser or FactoryGuidedDenoiser with cfg_scale != 1 "
|
||||
"or force_uncond_pass=True and a negative_context."
|
||||
)
|
||||
if audio_state is not None and not isinstance(uncond_audio, torch.Tensor):
|
||||
raise ValueError(
|
||||
"euler_cfg_pp_denoising_loop requires audio DenoisedLatentResult.uncond to be a tensor. "
|
||||
"Use GuidedDenoiser or FactoryGuidedDenoiser with cfg_scale != 1 "
|
||||
"or force_uncond_pass=True and a negative_context."
|
||||
)
|
||||
|
||||
if video_state is not None and denoised_video is not None:
|
||||
denoised_video = post_process_latent(denoised_video, video_state.denoise_mask, video_state.clean_latent)
|
||||
if audio_state is not None and denoised_audio is not None:
|
||||
denoised_audio = post_process_latent(denoised_audio, audio_state.denoise_mask, audio_state.clean_latent)
|
||||
|
||||
if sigmas[step_idx + 1] == 0:
|
||||
if video_state is not None and denoised_video is not None:
|
||||
video_state = replace(video_state, latent=denoised_video.to(model_dtype))
|
||||
if audio_state is not None and denoised_audio is not None:
|
||||
audio_state = replace(audio_state, latent=denoised_audio.to(model_dtype))
|
||||
return video_state, audio_state
|
||||
|
||||
# Draw noise consecutively from the same generator: video first, audio second.
|
||||
noise_video = new_noise_fn(video_state.latent, generator) if (video_state is not None and draw_noise) else None
|
||||
noise_audio = new_noise_fn(audio_state.latent, generator) if (audio_state is not None and draw_noise) else None
|
||||
|
||||
if video_state is not None and denoised_video is not None:
|
||||
x_next = stepper.step(
|
||||
sample=video_state.latent,
|
||||
denoised_sample=denoised_video,
|
||||
sigmas=sigmas,
|
||||
step_index=step_idx,
|
||||
uncond_denoised=uncond_video,
|
||||
noise=noise_video,
|
||||
)
|
||||
video_state = replace(video_state, latent=x_next.to(model_dtype))
|
||||
|
||||
if audio_state is not None and denoised_audio is not None:
|
||||
x_next = stepper.step(
|
||||
sample=audio_state.latent,
|
||||
denoised_sample=denoised_audio,
|
||||
sigmas=sigmas,
|
||||
step_index=step_idx,
|
||||
uncond_denoised=uncond_audio,
|
||||
noise=noise_audio,
|
||||
)
|
||||
audio_state = replace(audio_state, latent=x_next.to(model_dtype))
|
||||
|
||||
return video_state, audio_state
|
||||
|
||||
@@ -40,6 +40,36 @@ class PipelineComponents:
|
||||
self.audio_patchifier = AudioPatchifier(patch_size=1)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DenoisedLatentResult:
|
||||
"""Output of one denoiser call for a single modality.
|
||||
``denoised`` is the final blended prediction for this modality.
|
||||
The remaining fields carry the per-pass raw outputs from ``_guided_denoise``
|
||||
(all ``None`` for ``SimpleDenoiser``). Denoisers return a
|
||||
``(video_result, audio_result)`` tuple; either element may be ``None``
|
||||
for absent modalities.
|
||||
"""
|
||||
|
||||
denoised: torch.Tensor
|
||||
uncond: torch.Tensor | None = None
|
||||
cond: torch.Tensor | None = None
|
||||
ptb: torch.Tensor | None = None
|
||||
mod: torch.Tensor | None = None
|
||||
|
||||
@classmethod
|
||||
def result_or_none(
|
||||
cls,
|
||||
denoised: torch.Tensor | None,
|
||||
uncond: torch.Tensor | None = None,
|
||||
cond: torch.Tensor | None = None,
|
||||
ptb: torch.Tensor | None = None,
|
||||
mod: torch.Tensor | None = None,
|
||||
) -> DenoisedLatentResult | None:
|
||||
if denoised is None:
|
||||
return None
|
||||
return cls(denoised=denoised, uncond=uncond, cond=cond, ptb=ptb, mod=mod)
|
||||
|
||||
|
||||
class Denoiser(Protocol):
|
||||
"""Protocol for a denoiser that receives the transformer at call time.
|
||||
The transformer is not stored — it is passed as the first argument so the
|
||||
@@ -51,7 +81,8 @@ class Denoiser(Protocol):
|
||||
sigmas: 1-D tensor of sigma values for each diffusion step.
|
||||
step_index: Index of the current denoising step.
|
||||
Returns:
|
||||
``(denoised_video, denoised_audio)`` tensors (either may be ``None``).
|
||||
A ``(video_result, audio_result)`` tuple of :class:`DenoisedLatentResult`,
|
||||
either may be ``None`` for absent modalities.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
@@ -61,7 +92,7 @@ class Denoiser(Protocol):
|
||||
audio_state: LatentState | None,
|
||||
sigmas: torch.Tensor,
|
||||
step_index: int,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]: ...
|
||||
) -> tuple[DenoisedLatentResult | None, DenoisedLatentResult | None]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
Reference in New Issue
Block a user