feat(ltx2): full i2v conditioning + continuation latent port

Replaces the T2V-only stub with the full internal port:

* resolve_ltx2_images / _resize_and_center_crop / CRF re-encode
* load_ltx2_conditioning_image + load_ltx2_conditioning_video_clip
* _extract_video_latent / _insert_conditioning_latent
* build_ltx2_image_conditioning composes (clean_latent, denoise_mask)
  from images + video clips + continuation latents (stage1 vs stage2)
  — returns None for plain T2V

ForwardBatch + SamplingParam gain the missing fields the builder
reads (ltx2_images, ltx2_image_crf, ltx2_conditioning_latent_stage1/2,
ltx2_video_conditions). generate_video kwargs flow through
sampling_param.update -> ForwardBatch(**shallow_asdict(...)) so
Dreamverse's ltx2_image_crf=0.0 and continuation latents land on the
batch correctly.

68/68 LTX-2 tests still green.

Co-authored-by: Junda (David) Su <90978028+Davids048@users.noreply.github.com>
Co-authored-by: Matthew Noto <99706358+RandNMR73@users.noreply.github.com>
Co-authored-by: XOR-op <17672363+XOR-op@users.noreply.github.com>
Co-authored-by: Zhang Peiyuan <42993249+jzhang38@users.noreply.github.com>
This commit is contained in:
SolitaryThinker
2026-05-07 13:37:25 -07:00
co-authored by Junda Su Matthew Noto XOR-op Zhang Peiyuan
parent 040b7735c6
commit a62b8e1e8e
3 changed files with 446 additions and 65 deletions
+12
View File
@@ -117,6 +117,18 @@ class SamplingParam:
ltx2_stg_blocks_video: list[int] = field(default_factory=lambda: [29])
ltx2_stg_blocks_audio: list[int] = field(default_factory=lambda: [29])
# LTX-2 image / video / continuation conditioning. These flow from
# generate_video(...) kwargs through ``sampling_param.update(kwargs)``
# onto the ForwardBatch fields of the same name. ``ltx2_image_crf``
# gates the conditioning-image H.264 re-encode; the streaming
# session controller passes ``ltx2_image_crf=0.0`` because it
# conditions on already-decoded VAE-quality frames.
ltx2_images: list[tuple[str, int, float]] | None = None
ltx2_image_crf: float = 33.0
ltx2_conditioning_latent_stage1: Any | None = None
ltx2_conditioning_latent_stage2: Any | None = None
ltx2_video_conditions: list[tuple[list[str], int, float]] | None = None
# Stable Audio (T2A): clip start/end in seconds. Honored by
# `StableAudioConditioningStage` + `StableAudioDecodingStage`. Other
# families ignore them.
@@ -1,37 +1,53 @@
# SPDX-License-Identifier: Apache-2.0
"""LTX-2 image / continuation conditioning helpers.
"""FastVideo-native LTX-2 image-to-video conditioning helpers.
This is a *minimal* port of ``FastVideo-internal/.../ltx2_i2v_conditioning.py``
that captures just the surface needed for the spatial-refine (SR)
pipeline's stage-2 path:
Public-side port of ``FastVideo-internal/.../ltx2_i2v_conditioning.py``.
The module composes a ``clean_latent`` + ``denoise_mask`` pair that the
LTX-2 latent-prep + denoising stages mix into the noise tensor, so a
generated segment can be anchored to:
* the four ``ForwardBatch.extra`` keys (clean latent, denoise mask,
stage-1/stage-2 last-latent for continuation),
* :func:`apply_ltx2_gaussian_noiser` — used by both denoising and refine
to mix noise into the conditioning latent,
* :class:`LTX2ImageConditioningState` and a T2V-only
:func:`build_ltx2_image_conditioning` that returns ``None`` when the
request carries no image inputs and no continuation latents.
* one or more conditioning images at specific latent frame indices
(``ltx2_images``),
* a multi-frame conditioning video clip jointly VAE-encoded
(``ltx2_video_conditions``),
* a continuation latent carried over from the previous segment
(``ltx2_conditioning_latent_stage1`` / ``_stage2``).
The full image-loading path (CRF re-encode, decode, conditioning latent
extraction, multi-image insertion) lives in the internal module and
will be ported separately when the public surface needs i2v / mid-roll
continuation. SR text-to-video does not exercise it, so keeping this
module narrow keeps the public stage graph honest.
The streaming server's session controller populates the continuation
latents between segments; the legacy from_pretrained path passes
``ltx2_images`` / ``ltx2_image_crf`` through compat translation.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from io import BytesIO
from typing import TYPE_CHECKING
import numpy as np
import torch
import torch.nn.functional as F
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.logger import init_logger
from fastvideo.models.vision_utils import load_image
if TYPE_CHECKING:
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
try:
import av
except ImportError: # pragma: no cover - optional dependency
av = None
logger = init_logger(__name__)
LTX2_VIDEO_CLEAN_LATENT_KEY = "ltx2_video_clean_latent"
LTX2_VIDEO_DENOISE_MASK_KEY = "ltx2_video_denoise_mask"
LTX2_CONTINUATION_STAGE1_LAST_LATENT_KEY = "ltx2_continuation_stage1_last_latent"
LTX2_CONTINUATION_STAGE2_LAST_LATENT_KEY = "ltx2_continuation_stage2_last_latent"
LTX2_CONTINUATION_STAGE1_LAST_LATENT_KEY = ("ltx2_continuation_stage1_last_latent")
LTX2_CONTINUATION_STAGE2_LAST_LATENT_KEY = ("ltx2_continuation_stage2_last_latent")
# NOTE: hard-coded for continuation quality experiments. The first
# latent frame of the next clip is anchored to the previous clip's
# last latent at full strength.
LTX2_CONTINUATION_TARGET_FRAME_IDX = 0
LTX2_CONTINUATION_STRENGTH = 1.0
DEFAULT_LTX2_IMAGE_CRF = 33.0
@@ -39,38 +55,244 @@ DEFAULT_LTX2_IMAGE_CRF = 33.0
@dataclass
class LTX2ImageConditioningState:
"""Result of building image / continuation conditioning.
Mirrors the internal shape so the refine stage can read the same
fields. The T2V port only ever instantiates this in the conditioning
path; the refine code's ``image_conditioning is None`` branch is
what fires for plain text-to-video SR.
"""
"""Result of building image / continuation conditioning."""
clean_latent: torch.Tensor
denoise_mask: torch.Tensor
images: list[tuple[str, int, float]]
latent_conditioned: bool
latent_conditioned: bool = False
def resolve_ltx2_images(batch: ForwardBatch) -> list[tuple[str, int, float]]:
"""Collect any LTX-2 image conditioning inputs declared on the batch.
"""Collect any LTX-2 image conditioning inputs from the batch.
The full internal version pulls from a few legacy attribute names;
on the public batch we only support ``batch.ltx2_images`` (a list
of ``(path, frame_index, strength)`` triples). When the field is
missing or empty the SR path treats the request as T2V.
Falls back to ``batch.image_path`` for the simple single-image i2v
case (anchors the first latent frame at full strength).
"""
images = getattr(batch, "ltx2_images", None) or []
if not isinstance(images, list):
images = batch.ltx2_images
if images is None and batch.image_path:
images = [(batch.image_path, 0, 1.0)]
if not images:
return []
out: list[tuple[str, int, float]] = []
for entry in images:
if not isinstance(entry, list | tuple) or len(entry) < 3:
continue
path, frame_idx, strength = entry[0], entry[1], entry[2]
out.append((str(path), int(frame_idx), float(strength)))
return out
resolved: list[tuple[str, int, float]] = []
for item in images:
if not isinstance(item, tuple | list) or len(item) != 3:
raise ValueError("Each ltx2_images item must be a tuple/list of "
"(path, frame_idx, strength).")
image_path, frame_idx, strength = item
frame_idx_int = int(frame_idx)
strength_float = float(strength)
if frame_idx_int < 0:
raise ValueError(f"LTX-2 frame_idx must be >= 0, got {frame_idx_int}")
if strength_float < 0.0 or strength_float > 1.0:
raise ValueError(f"LTX-2 image conditioning strength must be in [0, 1], "
f"got {strength_float}")
resolved.append((str(image_path), frame_idx_int, strength_float))
return resolved
def _resize_and_center_crop(
tensor: torch.Tensor,
height: int,
width: int,
) -> torch.Tensor:
if tensor.ndim != 3:
raise ValueError(f"Expected image tensor [H, W, C], got shape {tuple(tensor.shape)}")
tensor = tensor.permute(2, 0, 1).unsqueeze(0)
_, _, src_h, src_w = tensor.shape
scale = max(height / src_h, width / src_w)
new_h = math.ceil(src_h * scale)
new_w = math.ceil(src_w * scale)
tensor = F.interpolate(
tensor,
size=(new_h, new_w),
mode="bilinear",
align_corners=False,
)
crop_top = (new_h - height) // 2
crop_left = (new_w - width) // 2
tensor = tensor[:, :, crop_top:crop_top + height, crop_left:crop_left + width]
return tensor.unsqueeze(2)
def _encode_single_frame(output_file: BytesIO, image_array: np.ndarray, crf: float) -> None:
container = av.open(output_file, "w", format="mp4")
try:
stream = container.add_stream(
"libx264",
rate=1,
options={
"crf": str(crf),
"preset": "veryfast",
},
)
height = image_array.shape[0] // 2 * 2
width = image_array.shape[1] // 2 * 2
image_array = image_array[:height, :width]
stream.height = height
stream.width = width
frame = av.VideoFrame.from_ndarray(image_array, format="rgb24").reformat(format="yuv420p")
container.mux(stream.encode(frame))
container.mux(stream.encode())
finally:
container.close()
def _decode_single_frame(video_file: BytesIO) -> np.ndarray:
container = av.open(video_file)
try:
stream = next(s for s in container.streams if s.type == "video")
frame = next(container.decode(stream))
finally:
container.close()
return frame.to_ndarray(format="rgb24")
def _preprocess_conditioning_image(
image: np.ndarray,
image_crf: float,
) -> np.ndarray:
"""H.264 CRF re-encode the conditioning image to match the
quantization the model was trained on. ``image_crf <= 0.0`` skips
the re-encode (used by the streaming server which conditions on
already-decoded VAE-quality frames)."""
if image_crf <= 0.0:
return image
if av is None:
logger.warning("[LTX2] PyAV is unavailable; skipping CRF "
"conditioning preprocessing.")
return image
with BytesIO() as output_file:
_encode_single_frame(output_file, image, image_crf)
encoded = output_file.getvalue()
with BytesIO(encoded) as video_file:
return _decode_single_frame(video_file)
def load_ltx2_conditioning_image(
image_path: str,
*,
height: int,
width: int,
dtype: torch.dtype,
device: torch.device,
image_crf: float,
) -> torch.Tensor:
image = load_image(image_path)
image_np = np.array(image)[..., :3]
image_np = _preprocess_conditioning_image(image_np, image_crf=image_crf)
image_tensor = torch.tensor(image_np, dtype=torch.float32, device=device)
image_tensor = _resize_and_center_crop(image_tensor, height, width)
image_tensor = (image_tensor / 127.5 - 1.0).to(device=device, dtype=dtype)
return image_tensor
def load_ltx2_conditioning_video_clip(
frame_paths: list[str],
*,
height: int,
width: int,
dtype: torch.dtype,
device: torch.device,
image_crf: float,
) -> torch.Tensor:
"""Load multiple frames and stack as ``[1, C, T, H, W]`` for joint
VAE encoding so the resulting latent captures temporal/motion info.
"""
frame_tensors: list[torch.Tensor] = []
for path in frame_paths:
image = load_image(path)
image_np = np.array(image)[..., :3]
image_np = _preprocess_conditioning_image(image_np, image_crf=image_crf)
t = torch.tensor(image_np, dtype=torch.float32, device=device)
# _resize_and_center_crop returns [1, C, 1, H, W]
t = _resize_and_center_crop(t, height, width)
frame_tensors.append(t)
# Concat along T dimension -> [1, C, T, H, W]
video = torch.cat(frame_tensors, dim=2)
return (video / 127.5 - 1.0).to(device=device, dtype=dtype)
def _extract_video_latent(vae: torch.nn.Module, image: torch.Tensor) -> torch.Tensor:
with torch.no_grad():
encoded = vae.encode(image)
latent_dist = getattr(encoded, "latent_dist", None)
if latent_dist is not None:
encoded = latent_dist
latent: torch.Tensor
if torch.is_tensor(encoded):
latent = encoded
elif hasattr(encoded, "mode"):
latent = encoded.mode()
elif hasattr(encoded, "sample"):
latent = encoded.sample()
elif (isinstance(encoded, tuple | list) and encoded and torch.is_tensor(encoded[0])):
latent = encoded[0]
else:
raise TypeError(f"Unsupported VAE encode output type: {type(encoded)}")
if latent.ndim == 4:
latent = latent.unsqueeze(2)
if latent.ndim != 5:
raise ValueError(f"Expected video latent with 5 dims [B,C,T,H,W], got "
f"{tuple(latent.shape)}")
return latent
def _insert_conditioning_latent(
*,
conditioning_latent: torch.Tensor,
clean_latent: torch.Tensor,
denoise_mask: torch.Tensor,
frame_idx: int,
strength: float,
source_name: str,
) -> None:
if conditioning_latent.ndim == 4:
conditioning_latent = conditioning_latent.unsqueeze(2)
if conditioning_latent.ndim != 5:
raise ValueError(f"LTX-2 {source_name} latent must have 5 dims [B,C,T,H,W], "
f"got {tuple(conditioning_latent.shape)}")
if conditioning_latent.shape[0] == 1 and clean_latent.shape[0] > 1:
conditioned_latent = conditioning_latent.expand(
clean_latent.shape[0],
-1,
-1,
-1,
-1,
)
elif conditioning_latent.shape[0] == clean_latent.shape[0]:
conditioned_latent = conditioning_latent
else:
raise ValueError(f"LTX-2 {source_name} latent batch mismatch: "
f"{conditioning_latent.shape[0]} vs {clean_latent.shape[0]}")
if conditioned_latent.shape[1] != clean_latent.shape[1]:
raise ValueError(f"LTX-2 {source_name} latent channels mismatch: "
f"{conditioned_latent.shape[1]} vs {clean_latent.shape[1]}")
if conditioned_latent.shape[-2:] != clean_latent.shape[-2:]:
raise ValueError(f"LTX-2 {source_name} latent spatial shape "
f"mismatch: {tuple(conditioned_latent.shape[-2:])} "
f"vs {tuple(clean_latent.shape[-2:])}")
frame_count = conditioned_latent.shape[2]
end_idx = frame_idx + frame_count
if frame_idx < 0 or end_idx > clean_latent.shape[2]:
raise ValueError(f"LTX-2 {source_name} latent frame range out of bounds: "
f"frame_idx={frame_idx}, frame_count={frame_count}, "
f"latent_frames={clean_latent.shape[2]}")
clean_latent[:, :, frame_idx:end_idx] = conditioned_latent.to(
device=clean_latent.device,
dtype=clean_latent.dtype,
)
denoise_mask[:, :, frame_idx:end_idx] = 1.0 - float(strength)
def build_ltx2_image_conditioning(
@@ -83,26 +305,152 @@ def build_ltx2_image_conditioning(
image_crf: float | None = None,
base_clean_latent: torch.Tensor | None = None,
) -> LTX2ImageConditioningState | None:
"""Return image-conditioning state, or ``None`` for plain T2V.
"""Build the (clean_latent, denoise_mask) state for the next segment.
The full internal builder handles three sources: image inputs,
continuation latents, and ``ltx2_video_conditions``. The SR
text-to-video path takes none of those, so the early-return
``None`` branch is what fires there. We retain the parameter shape
so future i2v / continuation upstreaming can drop in without
changing the refine stage call site.
Returns ``None`` for plain T2V (no images, no continuation, no
video conditions). The denoise mask is 1 where the model should
sample fresh, 0 where it should preserve the conditioning latent
exactly. ``base_clean_latent is None`` corresponds to stage 1
(fresh half-res latent); ``base_clean_latent`` set means stage 2
(already-upsampled latent from the upsampler stage).
"""
del vae, height, width, image_crf, base_clean_latent # unused in T2V
images = resolve_ltx2_images(batch)
has_latent_conditioning = (getattr(batch, "ltx2_conditioning_latent_stage1", None) is not None
or getattr(batch, "ltx2_conditioning_latent_stage2", None) is not None)
has_video_conditions = bool(getattr(batch, "ltx2_video_conditions", None))
if not images and not has_latent_conditioning and not has_video_conditions:
conditioning_latent_stage1 = getattr(batch, "ltx2_conditioning_latent_stage1", None)
conditioning_latent_stage2 = getattr(batch, "ltx2_conditioning_latent_stage2", None)
is_stage1_conditioning = base_clean_latent is None
is_stage2_conditioning = not is_stage1_conditioning
has_latent_conditioning = False
continuation_latent_to_insert: torch.Tensor | None = None
if (conditioning_latent_stage1 is not None and not torch.is_tensor(conditioning_latent_stage1)):
raise TypeError("LTX-2 stage1 continuation latent conditioning "
"expects a torch.Tensor.")
if (conditioning_latent_stage2 is not None and not torch.is_tensor(conditioning_latent_stage2)):
raise TypeError("LTX-2 stage2 continuation latent conditioning "
"expects a torch.Tensor.")
if (conditioning_latent_stage1 is None) != (conditioning_latent_stage2 is None):
raise ValueError("LTX-2 continuation expects both stage1 and stage2 "
"conditioning latents (or neither for first round).")
if is_stage1_conditioning and conditioning_latent_stage1 is not None:
has_latent_conditioning = True
continuation_latent_to_insert = conditioning_latent_stage1.to(
device=latents.device,
dtype=latents.dtype,
)
elif is_stage2_conditioning and conditioning_latent_stage2 is not None:
has_latent_conditioning = True
continuation_latent_to_insert = conditioning_latent_stage2.to(
device=latents.device,
dtype=latents.dtype,
)
video_conditions = getattr(batch, "ltx2_video_conditions", None) or []
if not images and not has_latent_conditioning and not video_conditions:
return None
raise NotImplementedError("LTX-2 image / continuation conditioning is not yet ported to the "
"public package. Open the corresponding upstream PR before "
"exercising this code path; SR text-to-video does not need it.")
clean_latent = (torch.zeros_like(latents) if base_clean_latent is None else base_clean_latent.clone())
denoise_mask = torch.ones(
(
latents.shape[0],
1,
latents.shape[2],
latents.shape[3],
latents.shape[4],
),
dtype=torch.float32,
device=latents.device,
)
if image_crf is None:
image_crf = getattr(batch, "ltx2_image_crf", DEFAULT_LTX2_IMAGE_CRF)
vae_param = next(vae.parameters(), None)
encoder_dtype = (vae_param.dtype if vae_param is not None else latents.dtype)
encoder_device = (vae_param.device if vae_param is not None else latents.device)
cache: dict[tuple[str, int, int, float], torch.Tensor] = {}
latent_conditioned = False
if has_latent_conditioning:
if continuation_latent_to_insert is None:
raise RuntimeError("LTX-2 continuation latent conditioning state is invalid.")
# NOTE: frame index and strength are intentionally hard-coded.
# We always anchor the first frame of the next clip at full
# strength to the previous clip's last latent.
_insert_conditioning_latent(
conditioning_latent=continuation_latent_to_insert,
clean_latent=clean_latent,
denoise_mask=denoise_mask,
frame_idx=LTX2_CONTINUATION_TARGET_FRAME_IDX,
strength=LTX2_CONTINUATION_STRENGTH,
source_name="continuation",
)
latent_conditioned = True
for image_path, frame_idx, strength in images:
cache_key = (image_path, height, width, float(image_crf))
image_latent = cache.get(cache_key)
if image_latent is None:
image_tensor = load_ltx2_conditioning_image(
image_path=image_path,
height=height,
width=width,
dtype=encoder_dtype,
device=encoder_device,
image_crf=float(image_crf),
)
image_latent = _extract_video_latent(vae, image_tensor).to(
device=latents.device,
dtype=latents.dtype,
)
cache[cache_key] = image_latent
_insert_conditioning_latent(
conditioning_latent=image_latent,
clean_latent=clean_latent,
denoise_mask=denoise_mask,
frame_idx=frame_idx,
strength=strength,
source_name="image",
)
for frame_paths, frame_idx, strength in video_conditions:
video_tensor = load_ltx2_conditioning_video_clip(
frame_paths,
height=height,
width=width,
dtype=encoder_dtype,
device=encoder_device,
image_crf=float(image_crf),
)
video_latent = _extract_video_latent(vae, video_tensor).to(
device=latents.device,
dtype=latents.dtype,
)
logger.info(
"[LTX2] Video-clip condition: %d frames -> "
"latent T=%d at frame_idx=%d strength=%.2f",
len(frame_paths),
video_latent.shape[2],
frame_idx,
strength,
)
_insert_conditioning_latent(
conditioning_latent=video_latent,
clean_latent=clean_latent,
denoise_mask=denoise_mask,
frame_idx=frame_idx,
strength=strength,
source_name="video_clip",
)
return LTX2ImageConditioningState(
clean_latent=clean_latent,
denoise_mask=denoise_mask,
images=images,
latent_conditioned=latent_conditioned,
)
def apply_ltx2_gaussian_noiser(
@@ -114,10 +462,9 @@ def apply_ltx2_gaussian_noiser(
) -> torch.Tensor:
"""Mix ``noise`` into ``clean_latent`` along ``denoise_mask`` * scale.
Identical to the internal helper. The mask is expected to be
broadcastable across channels and time; values close to 1 produce
near-pure noise (used in a fresh stage-2 latent), values near 0
leave the clean latent untouched (used in conditioning regions).
Values close to 1 in the mask produce near-pure noise (used in a
fresh stage-2 latent), values near 0 leave the clean latent
untouched (used in conditioning regions).
"""
scaled_mask = denoise_mask * float(noise_scale)
return (noise * scaled_mask + clean_latent * (1.0 - scaled_mask)).to(noise.dtype)
@@ -129,9 +476,8 @@ def post_process_ltx2_denoised(
denoise_mask: torch.Tensor,
clean_latent: torch.Tensor,
) -> torch.Tensor:
"""Inverse of :func:`apply_ltx2_gaussian_noiser` — restore the
conditioning regions of ``clean_latent`` outside the denoise mask
after the model has filled in the masked area."""
"""Restore the conditioning regions of ``clean_latent`` outside the
denoise mask after the model has filled in the masked area."""
return (denoised * denoise_mask + clean_latent.float() * (1.0 - denoise_mask)).to(denoised.dtype)
@@ -146,6 +492,8 @@ __all__ = [
"LTX2ImageConditioningState",
"apply_ltx2_gaussian_noiser",
"build_ltx2_image_conditioning",
"load_ltx2_conditioning_image",
"load_ltx2_conditioning_video_clip",
"post_process_ltx2_denoised",
"resolve_ltx2_images",
]
@@ -79,6 +79,27 @@ class ForwardBatch:
image_embeds: list[torch.Tensor] = field(default_factory=list)
pil_image: torch.Tensor | PIL.Image.Image | None = None
preprocessed_image: torch.Tensor | None = None
# LTX-2 image conditioning. Each entry is (path, frame_idx, strength)
# — strength 1.0 anchors the latent at frame_idx exactly to the
# encoded image; <1.0 mixes denoised content with the conditioning
# image. ``ltx2_image_crf`` controls the H.264 CRF re-encode applied
# to the image before VAE encoding (matches official LTX-2 quality
# adaptation; 0.0 disables the re-encode).
ltx2_images: list[tuple[str, int, float]] | None = None
ltx2_image_crf: float = 33.0
# Stage-specific continuation latents — populated by the streaming
# session controller between segments. ``stage1`` is the last latent
# from the previous segment's stage-1 denoise (half-res); ``stage2``
# is the last latent from the previous segment's stage-2 refine
# (full-res). The image-conditioning builder reads whichever the
# current pass needs (``base_clean_latent is None`` -> stage1).
ltx2_conditioning_latent_stage1: torch.Tensor | None = None
ltx2_conditioning_latent_stage2: torch.Tensor | None = None
# Video-clip conditioning: list of (frame_paths, frame_idx, strength)
# — multi-frame VAE-encoded conditioning so the resulting latent
# captures temporal/motion info (used by the streaming server's
# mid-roll continuation flow).
ltx2_video_conditions: list[tuple[list[str], int, float]] | None = None
# Text inputs
prompt: str | list[str] | None = None