Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f031fc576c | ||
|
|
451f521679 |
@@ -353,11 +353,14 @@ def request_to_sampling_param(
|
||||
request: GenerationRequest,
|
||||
*,
|
||||
model_path: str,
|
||||
workload_type: Any | None = None,
|
||||
) -> SamplingParam:
|
||||
"""Translate one request using the preset for its model and workload."""
|
||||
if request.plan is not None:
|
||||
raise NotImplementedError("GenerationRequest.plan is not wired into VideoGenerator yet")
|
||||
|
||||
sampling_param = SamplingParam.from_pretrained(model_path)
|
||||
sampling_param = (SamplingParam.from_pretrained(model_path)
|
||||
if workload_type is None else SamplingParam.from_pretrained(model_path, workload_type))
|
||||
if request.state is not None:
|
||||
_validate_continuation_state(request.state)
|
||||
sampling_param.continuation_state = request.state
|
||||
|
||||
@@ -187,7 +187,7 @@ class SamplingParam:
|
||||
def __post_init__(self) -> None:
|
||||
self.data_type = "video" if self.num_frames > 1 else "image"
|
||||
|
||||
def check_sampling_param(self):
|
||||
def check_sampling_param(self) -> None:
|
||||
if self.prompt_path and not self.prompt_path.endswith(".txt"):
|
||||
raise ValueError("prompt_path must be a txt file")
|
||||
|
||||
@@ -206,8 +206,9 @@ class SamplingParam:
|
||||
self.__post_init__()
|
||||
|
||||
@classmethod
|
||||
def from_pretrained(cls, model_path: str) -> SamplingParam:
|
||||
sampling_param = cls._from_preset(model_path)
|
||||
def from_pretrained(cls, model_path: str, workload_type: Any | None = None) -> SamplingParam:
|
||||
"""Build model/workload preset defaults, or fall back to generic defaults."""
|
||||
sampling_param = cls._from_preset(model_path, workload_type)
|
||||
if sampling_param is not None:
|
||||
return sampling_param
|
||||
|
||||
@@ -222,6 +223,7 @@ class SamplingParam:
|
||||
def _from_preset(
|
||||
cls,
|
||||
model_path: str,
|
||||
workload_type: Any | None = None,
|
||||
) -> SamplingParam | None:
|
||||
"""Build a SamplingParam from preset defaults.
|
||||
|
||||
@@ -232,7 +234,10 @@ class SamplingParam:
|
||||
from fastvideo.registry import get_preset_selection
|
||||
|
||||
try:
|
||||
preset_name, model_family = get_preset_selection(model_path)
|
||||
if workload_type is None:
|
||||
preset_name, model_family = get_preset_selection(model_path)
|
||||
else:
|
||||
preset_name, model_family = get_preset_selection(model_path, workload_type)
|
||||
except (ValueError, RuntimeError):
|
||||
return None
|
||||
if preset_name is None or model_family is None:
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastvideo.configs.models.encoders.reason1 import Reason1ArchConfig, Reason1
|
||||
from fastvideo.configs.models.encoders.gemma import LTX2GemmaConfig
|
||||
from fastvideo.configs.models.encoders.mistral3 import Mistral3TextConfig
|
||||
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig, LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.stable_audio_conditioner import (StableAudioConditionerArchConfig,
|
||||
StableAudioConditionerConfig)
|
||||
from fastvideo.configs.models.encoders.t5gemma import T5GemmaEncoderConfig
|
||||
@@ -20,5 +20,6 @@ __all__ = [
|
||||
"CLIPVisionConfig", "WAN2_1ControlCLIPVisionConfig", "LlamaConfig", "T5Config", "T5LargeConfig", "Qwen2_5_VLConfig",
|
||||
"Reason1ArchConfig", "Reason1Config", "LTX2GemmaConfig", "SiglipVisionConfig", "StableAudioConditionerArchConfig",
|
||||
"StableAudioConditionerConfig", "T5GemmaEncoderConfig", "Qwen3TextConfig", "Mistral3TextConfig",
|
||||
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLTextConfig"
|
||||
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLConfig",
|
||||
"LingBotVideoQwen3VLTextConfig"
|
||||
]
|
||||
|
||||
@@ -36,10 +36,9 @@ class TextEncoderArchConfig(EncoderArchConfig):
|
||||
default_factory=list) # mapping from huggingface weight names to custom names
|
||||
tokenizer_kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [])
|
||||
# When True, the tokenizer loader prefers AutoProcessor over AutoTokenizer
|
||||
# for encoders whose tokenizer dir ships a processor_config.json (e.g. Flux2
|
||||
# full's Mistral3 multimodal processor). Default False keeps every existing
|
||||
# encoder on the historical AutoTokenizer path.
|
||||
# When True, the tokenizer loader uses AutoProcessor for encoders that need
|
||||
# multimodal preprocessing. Default False keeps text-only encoders on the
|
||||
# historical AutoTokenizer path.
|
||||
require_processor: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3-VL text-only encoder configuration used by LingBot-Video."""
|
||||
"""Qwen3-VL encoder configurations used by LingBot-Video."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -41,6 +41,36 @@ class LingBotVideoQwen3VLTextArchConfig(Qwen3TextArchConfig):
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotVideoQwen3VLVisionArchConfig:
|
||||
"""Vision-tower architecture released with LingBot-Video's Qwen3-VL."""
|
||||
|
||||
depth: int = 24
|
||||
hidden_act: str = "gelu_pytorch_tanh"
|
||||
hidden_size: int = 1024
|
||||
in_channels: int = 3
|
||||
intermediate_size: int = 4096
|
||||
num_heads: int = 16
|
||||
num_position_embeddings: int = 2304
|
||||
out_hidden_size: int = 2560
|
||||
patch_size: int = 16
|
||||
spatial_merge_size: int = 2
|
||||
temporal_patch_size: int = 2
|
||||
deepstack_visual_indexes: tuple[int, ...] = (5, 11, 17)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotVideoQwen3VLArchConfig(LingBotVideoQwen3VLTextArchConfig):
|
||||
"""Compound language-and-vision architecture needed by TI2V conditioning."""
|
||||
|
||||
architectures: list[str] = field(default_factory=lambda: ["LingBotVideoQwen3VLModel"])
|
||||
vision_config: dict = field(default_factory=lambda: LingBotVideoQwen3VLVisionArchConfig().__dict__.copy())
|
||||
image_token_id: int = 151655
|
||||
video_token_id: int = 151656
|
||||
vision_start_token_id: int = 151652
|
||||
vision_end_token_id: int = 151653
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotVideoQwen3VLTextConfig(Qwen3TextConfig):
|
||||
"""FastVideo loader config for the LingBot-Video text-only Qwen3-VL path."""
|
||||
@@ -48,3 +78,10 @@ class LingBotVideoQwen3VLTextConfig(Qwen3TextConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=LingBotVideoQwen3VLTextArchConfig)
|
||||
prefix: str = "language_model"
|
||||
is_chat_model: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotVideoQwen3VLConfig(LingBotVideoQwen3VLTextConfig):
|
||||
"""FastVideo loader config for LingBot-Video's compound Qwen3-VL model."""
|
||||
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=LingBotVideoQwen3VLArchConfig)
|
||||
|
||||
@@ -31,7 +31,7 @@ class PipelineConfig:
|
||||
pipeline_config_path: str | None = None
|
||||
|
||||
# Video generation parameters
|
||||
embedded_cfg_scale: float = 6.0
|
||||
embedded_cfg_scale: float | None = 6.0
|
||||
flow_shift: float | None = None
|
||||
flow_shift_sr: float | None = None
|
||||
disable_autocast: bool = False
|
||||
@@ -233,7 +233,7 @@ class PipelineConfig:
|
||||
raise ValueError("model_path is required in kwargs")
|
||||
|
||||
# 1. Get the pipeline config class from the registry
|
||||
pipeline_config_cls = get_pipeline_config_cls_from_name(model_path)
|
||||
pipeline_config_cls = get_pipeline_config_cls_from_name(model_path, kwargs.get("workload_type"))
|
||||
|
||||
# 2. Instantiate PipelineConfig
|
||||
if pipeline_config_cls is None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Dense LingBot-Video T2V pipeline configuration."""
|
||||
"""LingBot-Video T2V and TI2V pipeline configurations."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,7 +9,7 @@ import torch
|
||||
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
|
||||
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
|
||||
from fastvideo.configs.models.encoders import BaseEncoderOutput
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
|
||||
from fastvideo.configs.models.vaes import WanVAEConfig
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
|
||||
@@ -54,7 +54,7 @@ class LingBotVideoT2VConfig(PipelineConfig):
|
||||
|
||||
dit_config: DiTConfig = field(default_factory=LingBotVideoConfig)
|
||||
vae_config: VAEConfig = field(default_factory=WanVAEConfig)
|
||||
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (LingBotVideoQwen3VLTextConfig(), ))
|
||||
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (LingBotVideoQwen3VLConfig(), ))
|
||||
preprocess_text_funcs: tuple[Callable, ...] = field(default_factory=lambda: (preprocess_lingbot_video_prompt, ))
|
||||
postprocess_text_funcs: tuple[Callable, ...] = field(default_factory=lambda: (postprocess_lingbot_video_text, ))
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
|
||||
@@ -71,3 +71,13 @@ class LingBotVideoT2VConfig(PipelineConfig):
|
||||
"""Load only the VAE decoder for the T2V workload."""
|
||||
self.vae_config.load_encoder = False
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotVideoTI2VConfig(LingBotVideoT2VConfig):
|
||||
"""TI2V wiring with the compound Qwen3-VL model and VAE encoder."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Load both VAE directions while custom stages preserve LingBot geometry."""
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
@@ -130,13 +130,17 @@ def _resolve_output_size(
|
||||
return fallback
|
||||
|
||||
|
||||
def _validate_request_stage_overrides(model_path: str, request: GenerationRequest) -> None:
|
||||
def _validate_request_stage_overrides(
|
||||
model_path: str,
|
||||
request: GenerationRequest,
|
||||
workload_type: Any | None = None,
|
||||
) -> None:
|
||||
"""Validate typed stage overrides against the model's registered preset."""
|
||||
if not request.stage_overrides:
|
||||
return
|
||||
from fastvideo.api.presets import validate_preset_selection
|
||||
from fastvideo.registry import get_preset_selection
|
||||
preset_name, model_family = get_preset_selection(model_path)
|
||||
preset_name, model_family = get_preset_selection(model_path, workload_type)
|
||||
if preset_name is None or model_family is None:
|
||||
raise ValueError(f"Model {model_path!r} has no preset for stage override validation")
|
||||
validate_preset_selection(
|
||||
@@ -462,6 +466,7 @@ class VideoGenerator:
|
||||
resolved_sampling_param = request_to_sampling_param(
|
||||
request,
|
||||
model_path=self.fastvideo_args.model_path,
|
||||
workload_type=self.fastvideo_args.workload_type,
|
||||
)
|
||||
return self._generate_video_impl(
|
||||
prompt=request.prompt,
|
||||
@@ -477,7 +482,11 @@ class VideoGenerator:
|
||||
self,
|
||||
request: GenerationRequest,
|
||||
) -> GenerationResult | list[GenerationResult]:
|
||||
_validate_request_stage_overrides(self.fastvideo_args.model_path, request)
|
||||
_validate_request_stage_overrides(
|
||||
self.fastvideo_args.model_path,
|
||||
request,
|
||||
self.fastvideo_args.workload_type,
|
||||
)
|
||||
if isinstance(request.prompt, list):
|
||||
if request.inputs.prompt_path is not None:
|
||||
raise ValueError("request.prompt list cannot be combined with request.inputs.prompt_path")
|
||||
@@ -512,6 +521,7 @@ class VideoGenerator:
|
||||
sampling_param = request_to_sampling_param(
|
||||
request,
|
||||
model_path=self.fastvideo_args.model_path,
|
||||
workload_type=self.fastvideo_args.workload_type,
|
||||
)
|
||||
result = self._generate_video_impl(
|
||||
prompt=request.prompt,
|
||||
@@ -537,7 +547,10 @@ class VideoGenerator:
|
||||
|
||||
# Handle batch processing from text file
|
||||
if sampling_param is None:
|
||||
sampling_param = SamplingParam.from_pretrained(fastvideo_args.model_path)
|
||||
sampling_param = SamplingParam.from_pretrained(
|
||||
fastvideo_args.model_path,
|
||||
fastvideo_args.workload_type,
|
||||
)
|
||||
|
||||
# Add action control inputs to kwargs if provided
|
||||
if mouse_cond is not None:
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Native LingBot-Video Qwen3-VL language model for text-only conditioning."""
|
||||
"""Native LingBot-Video Qwen3-VL language and vision conditioning models."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import itertools
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from fastvideo.configs.models.encoders import BaseEncoderOutput
|
||||
from fastvideo.layers.layernorm import RMSNorm
|
||||
@@ -17,11 +20,275 @@ from fastvideo.models.encoders.qwen3 import (
|
||||
Qwen3ForCausalLM,
|
||||
Qwen3MLP,
|
||||
)
|
||||
from fastvideo.models.loader.weight_utils import default_weight_loader
|
||||
|
||||
|
||||
def _vision_position_ids(grid_thw: torch.Tensor, merge_size: int) -> torch.Tensor:
|
||||
"""Lay out vision rotary positions in Qwen3-VL's spatial merge order."""
|
||||
position_ids = []
|
||||
for temporal, height, width in grid_thw.tolist():
|
||||
height_ids, width_ids = torch.meshgrid(
|
||||
torch.arange(height, device=grid_thw.device),
|
||||
torch.arange(width, device=grid_thw.device),
|
||||
indexing="ij",
|
||||
)
|
||||
block_shape = (height // merge_size, merge_size, width // merge_size, merge_size)
|
||||
height_ids = height_ids.reshape(block_shape).transpose(1, 2).flatten()
|
||||
width_ids = width_ids.reshape(block_shape).transpose(1, 2).flatten()
|
||||
position_ids.append(torch.stack((height_ids, width_ids), dim=-1).repeat(temporal, 1))
|
||||
return torch.cat(position_ids, dim=0)
|
||||
|
||||
|
||||
def _vision_bilinear_position_data(
|
||||
grid_thw: torch.Tensor,
|
||||
side: int,
|
||||
merge_size: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Compute Qwen3-VL's interpolated absolute-position lookup data."""
|
||||
index_parts: list[list[torch.Tensor]] = [[] for _ in range(4)]
|
||||
weight_parts: list[list[torch.Tensor]] = [[] for _ in range(4)]
|
||||
for temporal, height, width in grid_thw.tolist():
|
||||
height_grid = torch.linspace(0, side - 1, height, device=grid_thw.device)
|
||||
width_grid = torch.linspace(0, side - 1, width, device=grid_thw.device)
|
||||
height_floor, width_floor = height_grid.int(), width_grid.int()
|
||||
height_ceil = (height_floor + 1).clamp(max=side - 1)
|
||||
width_ceil = (width_floor + 1).clamp(max=side - 1)
|
||||
height_frac, width_frac = height_grid - height_floor, width_grid - width_floor
|
||||
floor_offset, ceil_offset = height_floor * side, height_ceil * side
|
||||
corner_indices = (
|
||||
(floor_offset[:, None] + width_floor[None, :]).flatten(),
|
||||
(floor_offset[:, None] + width_ceil[None, :]).flatten(),
|
||||
(ceil_offset[:, None] + width_floor[None, :]).flatten(),
|
||||
(ceil_offset[:, None] + width_ceil[None, :]).flatten(),
|
||||
)
|
||||
corner_weights = (
|
||||
((1 - height_frac)[:, None] * (1 - width_frac)[None, :]).flatten(),
|
||||
((1 - height_frac)[:, None] * width_frac[None, :]).flatten(),
|
||||
(height_frac[:, None] * (1 - width_frac)[None, :]).flatten(),
|
||||
(height_frac[:, None] * width_frac[None, :]).flatten(),
|
||||
)
|
||||
height_order = torch.arange(height, device=grid_thw.device).view(height // merge_size, merge_size)
|
||||
width_order = torch.arange(width, device=grid_thw.device).view(width // merge_size, merge_size)
|
||||
reorder = (
|
||||
(height_order[:, :, None, None] * width + width_order[None, None, :, :])
|
||||
.transpose(1, 2)
|
||||
.flatten()
|
||||
.repeat(temporal)
|
||||
)
|
||||
for index in range(4):
|
||||
index_parts[index].append(corner_indices[index][reorder])
|
||||
weight_parts[index].append(corner_weights[index][reorder])
|
||||
indices = torch.stack([torch.cat(part) for part in index_parts])
|
||||
weights = torch.stack([torch.cat(part) for part in weight_parts])
|
||||
return indices, weights
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionPatchEmbed(nn.Module):
|
||||
"""Convert processor patch rows into Qwen3-VL vision tokens."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace) -> None:
|
||||
super().__init__()
|
||||
self.in_channels = config.in_channels
|
||||
self.temporal_patch_size = config.temporal_patch_size
|
||||
self.patch_size = config.patch_size
|
||||
self.hidden_size = config.hidden_size
|
||||
kernel = (self.temporal_patch_size, self.patch_size, self.patch_size)
|
||||
self.proj = nn.Conv3d(self.in_channels, self.hidden_size, kernel_size=kernel, stride=kernel, bias=True)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
"""Apply the released Conv3d patch projection in its parameter dtype."""
|
||||
hidden_states = hidden_states.view(
|
||||
-1,
|
||||
self.in_channels,
|
||||
self.temporal_patch_size,
|
||||
self.patch_size,
|
||||
self.patch_size,
|
||||
)
|
||||
return self.proj(hidden_states.to(self.proj.weight.dtype)).view(-1, self.hidden_size)
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionMLP(nn.Module):
|
||||
"""Qwen3-VL vision feed-forward block with tanh-approximate GELU."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace) -> None:
|
||||
super().__init__()
|
||||
self.linear_fc1 = nn.Linear(config.hidden_size, config.intermediate_size, bias=True)
|
||||
self.linear_fc2 = nn.Linear(config.intermediate_size, config.hidden_size, bias=True)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
"""Apply the two released vision MLP projections."""
|
||||
return self.linear_fc2(F.gelu(self.linear_fc1(hidden_states), approximate="tanh"))
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionAttention(nn.Module):
|
||||
"""Packed non-causal Qwen3-VL vision attention using native SDPA."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = config.hidden_size
|
||||
self.num_heads = config.num_heads
|
||||
self.head_dim = self.hidden_size // self.num_heads
|
||||
self.scaling = self.head_dim**-0.5
|
||||
self.qkv = nn.Linear(self.hidden_size, self.hidden_size * 3, bias=True)
|
||||
self.proj = nn.Linear(self.hidden_size, self.hidden_size, bias=True)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
cu_seqlens: torch.Tensor,
|
||||
cos: torch.Tensor,
|
||||
sin: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Apply rotary embeddings and attend independently within each packed image."""
|
||||
sequence_length = hidden_states.shape[0]
|
||||
query, key, value = (
|
||||
self.qkv(hidden_states)
|
||||
.reshape(sequence_length, 3, self.num_heads, self.head_dim)
|
||||
.permute(1, 0, 2, 3)
|
||||
.unbind(0)
|
||||
)
|
||||
query_dtype, key_dtype = query.dtype, key.dtype
|
||||
cos, sin = cos.unsqueeze(-2).float(), sin.unsqueeze(-2).float()
|
||||
query_float, key_float = query.float(), key.float()
|
||||
query = (query_float * cos + self._rotate_half(query_float) * sin).to(query_dtype)
|
||||
key = (key_float * cos + self._rotate_half(key_float) * sin).to(key_dtype)
|
||||
query, key, value = [tensor.transpose(0, 1).unsqueeze(0) for tensor in (query, key, value)]
|
||||
lengths = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
|
||||
query_chunks, key_chunks, value_chunks = [torch.split(tensor, lengths, dim=2) for tensor in (query, key, value)]
|
||||
outputs = [
|
||||
F.scaled_dot_product_attention(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
dropout_p=0.0,
|
||||
is_causal=False,
|
||||
scale=self.scaling,
|
||||
).transpose(1, 2)
|
||||
for q, k, v in zip(query_chunks, key_chunks, value_chunks, strict=True)
|
||||
]
|
||||
return self.proj(torch.cat(outputs, dim=1).reshape(sequence_length, -1).contiguous())
|
||||
|
||||
@staticmethod
|
||||
def _rotate_half(hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
"""Rotate the two halves used by Qwen3-VL's NeoX rotary layout."""
|
||||
first, second = hidden_states.chunk(2, dim=-1)
|
||||
return torch.cat((-second, first), dim=-1)
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionBlock(nn.Module):
|
||||
"""One released Qwen3-VL vision transformer block."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.LayerNorm(config.hidden_size, eps=1e-6)
|
||||
self.norm2 = nn.LayerNorm(config.hidden_size, eps=1e-6)
|
||||
self.attn = LingBotVideoQwen3VLVisionAttention(config)
|
||||
self.mlp = LingBotVideoQwen3VLVisionMLP(config)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
cu_seqlens: torch.Tensor,
|
||||
cos: torch.Tensor,
|
||||
sin: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Apply attention and MLP residuals in the official order."""
|
||||
hidden_states = hidden_states + self.attn(self.norm1(hidden_states), cu_seqlens, cos, sin)
|
||||
return hidden_states + self.mlp(self.norm2(hidden_states))
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionPatchMerger(nn.Module):
|
||||
"""Merge each 2x2 vision-token block into language hidden width."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace, *, postshuffle_norm: bool) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = config.hidden_size * config.spatial_merge_size**2
|
||||
self.postshuffle_norm = postshuffle_norm
|
||||
norm_size = self.hidden_size if postshuffle_norm else config.hidden_size
|
||||
self.norm = nn.LayerNorm(norm_size, eps=1e-6)
|
||||
self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
|
||||
self.linear_fc2 = nn.Linear(self.hidden_size, config.out_hidden_size)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
"""Normalize at the released boundary, shuffle, project, and merge."""
|
||||
if self.postshuffle_norm:
|
||||
hidden_states = self.norm(hidden_states.view(-1, self.hidden_size))
|
||||
else:
|
||||
hidden_states = self.norm(hidden_states).view(-1, self.hidden_size)
|
||||
hidden_states = self.linear_fc1(hidden_states)
|
||||
return self.linear_fc2(F.gelu(hidden_states))
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLVisionModel(nn.Module):
|
||||
"""Native Qwen3-VL vision tower, including the three DeepStack outputs."""
|
||||
|
||||
def __init__(self, config: SimpleNamespace) -> None:
|
||||
"""Build the released patch, position, block, and merge structure."""
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.spatial_merge_size = config.spatial_merge_size
|
||||
self.patch_embed = LingBotVideoQwen3VLVisionPatchEmbed(config)
|
||||
self.pos_embed = nn.Embedding(config.num_position_embeddings, config.hidden_size)
|
||||
self.num_grid_per_side = int(config.num_position_embeddings**0.5)
|
||||
self.blocks = nn.ModuleList(LingBotVideoQwen3VLVisionBlock(config) for _ in range(config.depth))
|
||||
self.merger = LingBotVideoQwen3VLVisionPatchMerger(config, postshuffle_norm=False)
|
||||
self.deepstack_visual_indexes = tuple(config.deepstack_visual_indexes)
|
||||
self.deepstack_merger_list = nn.ModuleList(
|
||||
LingBotVideoQwen3VLVisionPatchMerger(config, postshuffle_norm=True)
|
||||
for _ in self.deepstack_visual_indexes
|
||||
)
|
||||
head_dim = config.hidden_size // config.num_heads
|
||||
inv_freq = 1.0 / (10000.0 ** (torch.arange(0, head_dim // 2, 2).float() / (head_dim // 2)))
|
||||
self.register_buffer("rotary_inv_freq", inv_freq, persistent=False)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
pixel_values: torch.Tensor,
|
||||
grid_thw: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, list[torch.Tensor]]:
|
||||
"""Encode processor patches into merged and DeepStack visual features."""
|
||||
indices, weights = _vision_bilinear_position_data(
|
||||
grid_thw,
|
||||
self.num_grid_per_side,
|
||||
self.spatial_merge_size,
|
||||
)
|
||||
position_ids = _vision_position_ids(grid_thw, self.spatial_merge_size)
|
||||
cu_seqlens = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).cumsum(
|
||||
dim=0,
|
||||
dtype=torch.int32,
|
||||
)
|
||||
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
|
||||
hidden_states = self.patch_embed(pixel_values)
|
||||
position_embeds = (self.pos_embed(indices) * weights[:, :, None]).sum(0)
|
||||
hidden_states = hidden_states + position_embeds.to(hidden_states.dtype)
|
||||
rotary = (position_ids.unsqueeze(-1) * self.rotary_inv_freq).flatten(1)
|
||||
rotary = torch.cat((rotary, rotary), dim=-1)
|
||||
cos, sin = rotary.cos(), rotary.sin()
|
||||
deepstack_features = []
|
||||
for layer_index, block in enumerate(self.blocks):
|
||||
hidden_states = block(hidden_states, cu_seqlens, cos, sin)
|
||||
if layer_index in self.deepstack_visual_indexes:
|
||||
merger_index = self.deepstack_visual_indexes.index(layer_index)
|
||||
deepstack_features.append(self.deepstack_merger_list[merger_index](hidden_states))
|
||||
return self.merger(hidden_states), deepstack_features
|
||||
|
||||
|
||||
class LingBotVideoQwen3VLAttention(Qwen3Attention):
|
||||
"""Qwen3-VL attention with the official masked repeat-K/V SDPA path."""
|
||||
|
||||
def __init__(self, config: Any, **kwargs: Any) -> None:
|
||||
"""Keep the interleaved multimodal RoPE section sizes with attention."""
|
||||
super().__init__(config=config, **kwargs)
|
||||
self.mrope_section = config.mrope_section
|
||||
inverse_frequency = 1.0 / (
|
||||
self.rope_theta
|
||||
** (
|
||||
torch.arange(0, self.head_dim, 2, dtype=torch.int64, device="cpu").float()
|
||||
/ self.head_dim
|
||||
)
|
||||
)
|
||||
self.register_buffer("mrope_inv_freq", inverse_frequency, persistent=False)
|
||||
|
||||
def _apply_qwen3_vl_rope(
|
||||
self,
|
||||
positions: torch.Tensor,
|
||||
@@ -29,6 +296,25 @@ class LingBotVideoQwen3VLAttention(Qwen3Attention):
|
||||
key: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Apply NeoX RoPE with Qwen3-VL's input-dtype multiply-add ordering."""
|
||||
if positions.ndim == 3:
|
||||
inverse_frequency = self.mrope_inv_freq.to(query.device)
|
||||
expanded_frequency = inverse_frequency[None, None, :, None].expand(
|
||||
3,
|
||||
positions.shape[1],
|
||||
-1,
|
||||
1,
|
||||
)
|
||||
expanded_positions = positions[:, :, None, :].float()
|
||||
frequencies = (expanded_frequency.float() @ expanded_positions.float()).transpose(2, 3)
|
||||
frequencies_t = frequencies[0]
|
||||
for dimension, offset in enumerate((1, 2), start=1):
|
||||
length = self.mrope_section[dimension] * 3
|
||||
index = slice(offset, length, 3)
|
||||
frequencies_t[..., index] = frequencies[dimension, ..., index]
|
||||
embeddings = torch.cat((frequencies_t, frequencies_t), dim=-1)
|
||||
cos = embeddings.cos().to(query.dtype).unsqueeze(2)
|
||||
sin = embeddings.sin().to(query.dtype).unsqueeze(2)
|
||||
return self._rotate(query, cos, sin), self._rotate(key, cos, sin)
|
||||
flat_positions = positions.flatten()
|
||||
cos_sin = self.rotary_emb.cos_sin_cache.index_select(0, flat_positions)
|
||||
cos_half, sin_half = cos_sin.chunk(2, dim=-1)
|
||||
@@ -41,12 +327,14 @@ class LingBotVideoQwen3VLAttention(Qwen3Attention):
|
||||
cos = cos.view(*query.shape[:2], 1, self.head_dim)
|
||||
sin = sin.view(*query.shape[:2], 1, self.head_dim)
|
||||
|
||||
def rotate(tensor: torch.Tensor) -> torch.Tensor:
|
||||
first, second = tensor.chunk(2, dim=-1)
|
||||
rotated = torch.cat((-second, first), dim=-1)
|
||||
return tensor * cos + rotated * sin
|
||||
return self._rotate(query, cos, sin), self._rotate(key, cos, sin)
|
||||
|
||||
return rotate(query), rotate(key)
|
||||
@staticmethod
|
||||
def _rotate(tensor: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
||||
"""Apply Qwen3-VL's half-rotation multiply-add ordering."""
|
||||
first, second = tensor.chunk(2, dim=-1)
|
||||
rotated = torch.cat((-second, first), dim=-1)
|
||||
return tensor * cos + rotated * sin
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -180,6 +468,8 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
|
||||
attention_mask: torch.Tensor | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
output_hidden_states: bool | None = None,
|
||||
visual_pos_masks: torch.Tensor | None = None,
|
||||
deepstack_visual_embeds: list[torch.Tensor] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BaseEncoderOutput:
|
||||
"""Run explicit Qwen3-VL layers and return the requested hidden-state tuple."""
|
||||
@@ -197,10 +487,16 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
|
||||
if attention_mask is not None and bool(attention_mask.to(torch.bool).all()):
|
||||
attention_mask = None
|
||||
all_hidden_states: tuple[torch.Tensor, ...] | None = () if output_hidden_states else None
|
||||
for layer in self.layers:
|
||||
for layer_index, layer in enumerate(self.layers):
|
||||
if all_hidden_states is not None:
|
||||
all_hidden_states += (hidden_states, )
|
||||
hidden_states = layer(position_ids, hidden_states, attention_mask)
|
||||
if deepstack_visual_embeds is not None and layer_index < len(deepstack_visual_embeds):
|
||||
if visual_pos_masks is None:
|
||||
raise ValueError("visual_pos_masks is required with DeepStack features")
|
||||
hidden_states = hidden_states.clone()
|
||||
visual_states = deepstack_visual_embeds[layer_index].to(hidden_states.device, hidden_states.dtype)
|
||||
hidden_states[visual_pos_masks] = hidden_states[visual_pos_masks] + visual_states
|
||||
hidden_states = self.norm(hidden_states)
|
||||
if all_hidden_states is not None:
|
||||
all_hidden_states += (hidden_states, )
|
||||
@@ -218,4 +514,159 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
|
||||
return super().load_weights(language_weights)
|
||||
|
||||
|
||||
EntryClass = LingBotVideoQwen3VLTextModel
|
||||
class LingBotVideoQwen3VLModel(LingBotVideoQwen3VLTextModel):
|
||||
"""Compound native Qwen3-VL encoder for LingBot-Video TI2V prompts."""
|
||||
|
||||
def __init__(self, config: Any) -> None:
|
||||
"""Add the released visual tower without changing native language keys."""
|
||||
super().__init__(config)
|
||||
vision_config = config.vision_config
|
||||
if isinstance(vision_config, dict):
|
||||
vision_config = SimpleNamespace(**vision_config)
|
||||
self.visual = LingBotVideoQwen3VLVisionModel(vision_config)
|
||||
self.image_token_id = config.image_token_id
|
||||
|
||||
@staticmethod
|
||||
def _multimodal_vision_positions(
|
||||
start_position: int,
|
||||
grid_thw: torch.Tensor,
|
||||
merge_size: int,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
"""Create temporal, height, and width positions for one image grid."""
|
||||
temporal = grid_thw[0].item()
|
||||
height = grid_thw[1].item() // merge_size
|
||||
width = grid_thw[2].item() // merge_size
|
||||
temporal_grid, height_grid, width_grid = torch.meshgrid(
|
||||
torch.arange(temporal, device=device),
|
||||
torch.arange(height, device=device) + start_position,
|
||||
torch.arange(width, device=device) + start_position,
|
||||
indexing="ij",
|
||||
)
|
||||
positions = torch.stack((temporal_grid, height_grid, width_grid), dim=0).reshape(3, -1)
|
||||
positions[0] += start_position
|
||||
return positions
|
||||
|
||||
def _get_rope_index(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
mm_token_type_ids: torch.Tensor,
|
||||
image_grid_thw: torch.Tensor,
|
||||
attention_mask: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
"""Build Qwen3-VL interleaved text/image MRoPE positions for each prompt."""
|
||||
merge_size = self.visual.spatial_merge_size
|
||||
position_ids = torch.zeros(
|
||||
3,
|
||||
input_ids.shape[0],
|
||||
input_ids.shape[1],
|
||||
dtype=input_ids.dtype,
|
||||
device=input_ids.device,
|
||||
)
|
||||
grid_iterator = iter(image_grid_thw)
|
||||
for batch_index, token_types in enumerate(mm_token_type_ids):
|
||||
active = attention_mask[batch_index].bool() if attention_mask is not None else None
|
||||
current_types = token_types[active] if active is not None else token_types
|
||||
groups = []
|
||||
for modality, group in itertools.groupby(enumerate(current_types.tolist()), lambda pair: pair[1]):
|
||||
entries = list(group)
|
||||
groups.append((modality, entries[0][0], entries[-1][0] + 1))
|
||||
current_position = 0
|
||||
position_parts = []
|
||||
for modality, start, end in groups:
|
||||
if modality == 0:
|
||||
length = end - start
|
||||
text_positions = torch.arange(length, device=input_ids.device).view(1, -1).expand(3, -1)
|
||||
position_parts.append(text_positions + current_position)
|
||||
current_position += length
|
||||
elif modality == 1:
|
||||
grid = next(grid_iterator)
|
||||
position_parts.append(
|
||||
self._multimodal_vision_positions(
|
||||
current_position,
|
||||
grid,
|
||||
merge_size,
|
||||
input_ids.device,
|
||||
)
|
||||
)
|
||||
current_position += max(grid[1], grid[2]).item() // merge_size
|
||||
else:
|
||||
raise ValueError("LingBot-Video TI2V accepts image tokens but not video tokens")
|
||||
positions = torch.cat(position_parts, dim=1)
|
||||
if active is None:
|
||||
position_ids[:, batch_index] = positions
|
||||
else:
|
||||
position_ids[:, batch_index, active] = positions
|
||||
return position_ids
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None = None,
|
||||
position_ids: torch.Tensor | None = None,
|
||||
attention_mask: torch.Tensor | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
output_hidden_states: bool | None = None,
|
||||
pixel_values: torch.Tensor | None = None,
|
||||
image_grid_thw: torch.Tensor | None = None,
|
||||
mm_token_type_ids: torch.Tensor | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BaseEncoderOutput:
|
||||
"""Replace image placeholders, add DeepStack features, and encode the prompt."""
|
||||
if pixel_values is None:
|
||||
return super().forward(
|
||||
input_ids=input_ids,
|
||||
position_ids=position_ids,
|
||||
attention_mask=attention_mask,
|
||||
inputs_embeds=inputs_embeds,
|
||||
output_hidden_states=output_hidden_states,
|
||||
**kwargs,
|
||||
)
|
||||
if input_ids is None or inputs_embeds is not None:
|
||||
raise ValueError("multimodal conditioning requires input_ids and no inputs_embeds")
|
||||
if image_grid_thw is None or mm_token_type_ids is None:
|
||||
raise ValueError("image_grid_thw and mm_token_type_ids are required with pixel_values")
|
||||
inputs_embeds = self.get_input_embeddings(input_ids)
|
||||
visual_features, deepstack_features = self.visual(
|
||||
pixel_values.to(self.visual.patch_embed.proj.weight.dtype),
|
||||
image_grid_thw,
|
||||
)
|
||||
visual_features = visual_features.to(inputs_embeds.device, inputs_embeds.dtype)
|
||||
image_mask = input_ids == self.image_token_id
|
||||
expected = image_mask.sum().item()
|
||||
if expected != visual_features.shape[0]:
|
||||
raise ValueError(f"image tokens ({expected}) do not match visual features ({visual_features.shape[0]})")
|
||||
inputs_embeds = inputs_embeds.masked_scatter(image_mask.unsqueeze(-1), visual_features)
|
||||
if position_ids is None:
|
||||
position_ids = self._get_rope_index(input_ids, mm_token_type_ids, image_grid_thw, attention_mask)
|
||||
return super().forward(
|
||||
input_ids=None,
|
||||
position_ids=position_ids,
|
||||
attention_mask=attention_mask,
|
||||
inputs_embeds=inputs_embeds,
|
||||
output_hidden_states=output_hidden_states,
|
||||
visual_pos_masks=image_mask,
|
||||
deepstack_visual_embeds=deepstack_features,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Load visual tensors directly and delegate fused language tensors."""
|
||||
visual_parameters = dict(self.visual.named_parameters())
|
||||
language_weights = []
|
||||
loaded = set()
|
||||
for name, tensor in weights:
|
||||
visual_name = None
|
||||
if name.startswith("model.visual."):
|
||||
visual_name = name[len("model.visual.") :]
|
||||
elif name.startswith("visual."):
|
||||
visual_name = name[len("visual.") :]
|
||||
if visual_name is None:
|
||||
language_weights.append((name, tensor))
|
||||
elif "rotary_pos_emb.inv_freq" not in visual_name:
|
||||
default_weight_loader(visual_parameters[visual_name], tensor)
|
||||
loaded.add(f"visual.{visual_name}")
|
||||
loaded.update(super().load_weights(language_weights))
|
||||
return loaded
|
||||
|
||||
|
||||
EntryClass = [LingBotVideoQwen3VLTextModel, LingBotVideoQwen3VLModel]
|
||||
|
||||
@@ -627,11 +627,9 @@ class TokenizerLoader(ComponentLoader):
|
||||
# If parsing fails, fall through to AutoTokenizer below.
|
||||
pass
|
||||
|
||||
# Only Flux2 full's Mistral3 (require_processor=True) must load via
|
||||
# AutoProcessor. Gate the processor_config.json shortcut on that flag so
|
||||
# existing encoders (e.g. HunyuanVideo 1.5 / Qwen2.5-VL) stay on the
|
||||
# historical AutoTokenizer path below even if their tokenizer dir happens
|
||||
# to ship a processor_config.json.
|
||||
# Multimodal encoders with require_processor=True must load the complete
|
||||
# processor (text + other modal). LingBot Qwen3-VL declares it in preprocessor_config.json,
|
||||
# while Mistral3 ship it via processor_config.json.
|
||||
require_processor = False
|
||||
if hasattr(fastvideo_args.pipeline_config, "text_encoder_configs"):
|
||||
try:
|
||||
@@ -641,7 +639,7 @@ class TokenizerLoader(ComponentLoader):
|
||||
except Exception:
|
||||
require_processor = False
|
||||
|
||||
if require_processor and os.path.exists(os.path.join(resolved_model_path, "processor_config.json")):
|
||||
if require_processor:
|
||||
processor = AutoProcessor.from_pretrained(
|
||||
resolved_model_path,
|
||||
local_files_only=os.path.isdir(resolved_model_path),
|
||||
|
||||
@@ -232,8 +232,16 @@ class WanRMS_norm(nn.Module):
|
||||
self.bias = nn.Parameter(torch.zeros(shape)) if bias else 0.0
|
||||
|
||||
def forward(self, x):
|
||||
return F.normalize(x, dim=(1 if self.channel_first else
|
||||
-1)) * self.scale * self.gamma + self.bias
|
||||
# Match Diffusers v0.39.0 WanRMS_norm.forward for exact numerical alignment:
|
||||
# https://github.com/huggingface/diffusers/blob/v0.39.0/src/diffusers/models/autoencoders/autoencoder_kl_wan.py#L199-L205
|
||||
needs_fp32_normalize = x.dtype in (torch.float16, torch.bfloat16) or any(
|
||||
value in str(x.dtype) for value in ("float4_", "float8_")
|
||||
)
|
||||
normalized = F.normalize(
|
||||
x.float() if needs_fp32_normalize else x,
|
||||
dim=(1 if self.channel_first else -1),
|
||||
).to(x.dtype)
|
||||
return normalized * self.scale * self.gamma + self.bias
|
||||
|
||||
|
||||
class WanUpsample(nn.Upsample):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Stage-composed LingBot-Video Dense and MoE/refiner T2V pipeline."""
|
||||
"""Stage-composed LingBot-Video T2V and TI2V pipelines."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -7,9 +7,13 @@ from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.pipelines import ComposedPipelineBase, LoRAPipeline
|
||||
from fastvideo.pipelines.basic.lingbot_video.stages import (
|
||||
LingBotVideoDenoisingStage,
|
||||
LingBotVideoImageInputValidationStage,
|
||||
LingBotVideoImageLatentPreparationStage,
|
||||
LingBotVideoImagePromptEncodingStage,
|
||||
LingBotVideoInputValidationStage,
|
||||
LingBotVideoLatentPreparationStage,
|
||||
LingBotVideoRefinerPreparationStage,
|
||||
LingBotVideoRefinerTextEncodingStage,
|
||||
)
|
||||
from fastvideo.pipelines.stages import (
|
||||
ConditioningStage,
|
||||
@@ -103,4 +107,74 @@ class LingBotVideoPipeline(LoRAPipeline, ComposedPipelineBase):
|
||||
)
|
||||
|
||||
|
||||
EntryClass = LingBotVideoPipeline
|
||||
class LingBotVideoImageToVideoPipeline(LingBotVideoPipeline):
|
||||
"""TI2V pipeline with fixed clean frame-zero conditioning in both stages."""
|
||||
|
||||
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
"""Create image-conditioned base stages and optional text-only refinement."""
|
||||
refiner = self.get_module("transformer_2")
|
||||
self.add_stage(
|
||||
"input_validation_stage",
|
||||
LingBotVideoImageInputValidationStage(refiner_enabled=refiner is not None),
|
||||
)
|
||||
self.add_stage(
|
||||
"prompt_encoding_stage",
|
||||
LingBotVideoImagePromptEncodingStage(
|
||||
text_encoder=self.get_module("text_encoder"),
|
||||
processor=self.get_module("tokenizer"),
|
||||
),
|
||||
)
|
||||
self.add_stage("conditioning_stage", ConditioningStage())
|
||||
self.add_stage(
|
||||
"image_latent_preparation_stage",
|
||||
LingBotVideoImageLatentPreparationStage(vae=self.get_module("vae")),
|
||||
)
|
||||
self.add_stage(
|
||||
"timestep_preparation_stage",
|
||||
TimestepPreparationStage(scheduler=self.get_module("scheduler")),
|
||||
)
|
||||
self.add_stage(
|
||||
"latent_preparation_stage",
|
||||
LingBotVideoLatentPreparationStage(transformer=self.get_module("transformer")),
|
||||
)
|
||||
self.add_stage(
|
||||
"denoising_stage",
|
||||
LingBotVideoDenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"decoding_stage",
|
||||
DecodingStage(vae=self.get_module("vae"), pipeline=self),
|
||||
)
|
||||
if refiner is not None:
|
||||
self.add_stage(
|
||||
"refiner_preparation_stage",
|
||||
LingBotVideoRefinerPreparationStage(
|
||||
vae=self.get_module("vae"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"refiner_prompt_encoding_stage",
|
||||
LingBotVideoRefinerTextEncodingStage(
|
||||
text_encoder=self.get_module("text_encoder"),
|
||||
processor=self.get_module("tokenizer"),
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"refiner_denoising_stage",
|
||||
LingBotVideoDenoisingStage(
|
||||
transformer=refiner,
|
||||
scheduler=self.get_module("scheduler"),
|
||||
refiner=True,
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"refiner_decoding_stage",
|
||||
DecodingStage(vae=self.get_module("vae"), pipeline=self),
|
||||
)
|
||||
|
||||
|
||||
EntryClass = [LingBotVideoPipeline, LingBotVideoImageToVideoPipeline]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Official LingBot-Video T2V inference presets."""
|
||||
"""Official LingBot-Video T2V and TI2V inference presets."""
|
||||
|
||||
from fastvideo.api.presets import InferencePreset, PresetStageSpec
|
||||
|
||||
@@ -89,4 +89,63 @@ LINGBOT_VIDEO_MOE_REFINER_T2V = InferencePreset(
|
||||
},
|
||||
)
|
||||
|
||||
ALL_PRESETS = (LINGBOT_VIDEO_DENSE_T2V, LINGBOT_VIDEO_MOE_REFINER_T2V)
|
||||
LINGBOT_VIDEO_DENSE_TI2V = InferencePreset(
|
||||
name="lingbot_video_dense_ti2v",
|
||||
version=1,
|
||||
model_family="lingbot_video",
|
||||
description="LingBot-Video Dense 1.3B text-and-image-to-video at 480p",
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"height": 480,
|
||||
"width": 832,
|
||||
"num_frames": 121,
|
||||
"fps": 24,
|
||||
"num_inference_steps": 40,
|
||||
"guidance_scale": 3.0,
|
||||
"batch_cfg": True,
|
||||
"seed": 42,
|
||||
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
LINGBOT_VIDEO_MOE_REFINER_TI2V = InferencePreset(
|
||||
name="lingbot_video_moe_refiner_ti2v",
|
||||
version=1,
|
||||
model_family="lingbot_video",
|
||||
description="LingBot-Video MoE 30B-A3B TI2V with clean-frame 1080p refinement",
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, _REFINE_STAGE),
|
||||
defaults={
|
||||
"height": 480,
|
||||
"width": 832,
|
||||
"height_sr": 1088,
|
||||
"width_sr": 1920,
|
||||
"num_frames": 121,
|
||||
"fps": 24,
|
||||
"num_inference_steps": 40,
|
||||
"num_inference_steps_sr": 8,
|
||||
"guidance_scale": 3.0,
|
||||
"guidance_scale_2": 3.0,
|
||||
"batch_cfg": True,
|
||||
"t_thresh": 0.85,
|
||||
"seed": 42,
|
||||
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
|
||||
},
|
||||
stage_defaults={
|
||||
"refine": {
|
||||
"height_sr": 1088,
|
||||
"width_sr": 1920,
|
||||
"num_inference_steps_sr": 8,
|
||||
"guidance_scale_2": 3.0,
|
||||
"t_thresh": 0.85,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
ALL_PRESETS = (
|
||||
LINGBOT_VIDEO_DENSE_T2V,
|
||||
LINGBOT_VIDEO_MOE_REFINER_T2V,
|
||||
LINGBOT_VIDEO_DENSE_TI2V,
|
||||
LINGBOT_VIDEO_MOE_REFINER_TI2V,
|
||||
)
|
||||
|
||||
@@ -3,17 +3,68 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
from torch.distributed.tensor import DTensor
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.input_validation import InputValidationStage
|
||||
from fastvideo.pipelines.stages.text_encoding import TextEncodingStage
|
||||
|
||||
LINGBOT_VIDEO_REFINER_TAIL_STEPS = 2
|
||||
LINGBOT_VIDEO_IMAGE_TEMPLATE = "<|vision_start|><|image_pad|><|vision_end|>"
|
||||
LINGBOT_VIDEO_IMAGE_MIN_TOKENS = 4
|
||||
LINGBOT_VIDEO_IMAGE_MAX_TOKENS = 16384
|
||||
LINGBOT_VIDEO_IMAGE_MAX_RATIO = 200
|
||||
|
||||
|
||||
def _smart_resize_image(height: int, width: int, factor: int) -> tuple[int, int]:
|
||||
"""Match Qwen3-VL's bounded patch-grid resize used by LingBot-Video."""
|
||||
if max(height, width) / min(height, width) > LINGBOT_VIDEO_IMAGE_MAX_RATIO:
|
||||
raise ValueError(f"absolute image aspect ratio must be smaller than {LINGBOT_VIDEO_IMAGE_MAX_RATIO}")
|
||||
min_pixels = LINGBOT_VIDEO_IMAGE_MIN_TOKENS * factor**2
|
||||
max_pixels = LINGBOT_VIDEO_IMAGE_MAX_TOKENS * factor**2
|
||||
resized_height = max(factor, round(height / factor) * factor)
|
||||
resized_width = max(factor, round(width / factor) * factor)
|
||||
if resized_height * resized_width > max_pixels:
|
||||
beta = math.sqrt((height * width) / max_pixels)
|
||||
resized_height = math.floor(height / beta / factor) * factor
|
||||
resized_width = math.floor(width / beta / factor) * factor
|
||||
elif resized_height * resized_width < min_pixels:
|
||||
beta = math.sqrt(min_pixels / (height * width))
|
||||
resized_height = math.ceil(height * beta / factor) * factor
|
||||
resized_width = math.ceil(width * beta / factor) * factor
|
||||
return resized_height, resized_width
|
||||
|
||||
|
||||
def _preprocess_condition_image(image: Image.Image, height: int, width: int) -> torch.Tensor:
|
||||
"""Resize and center-crop the clean condition with released uint8 arithmetic."""
|
||||
raw = torch.from_numpy(np.array(image.convert("RGB"))).permute(2, 0, 1).unsqueeze(0).contiguous()
|
||||
source_height, source_width = raw.shape[-2:]
|
||||
scale = max(height / source_height, width / source_width)
|
||||
resized_height = max(math.ceil(source_height * scale), height)
|
||||
resized_width = max(math.ceil(source_width * scale), width)
|
||||
resized = F.interpolate(raw, size=(resized_height, resized_width), mode="bilinear", align_corners=False)
|
||||
top = int(round((resized_height - height) / 2.0))
|
||||
left = int(round((resized_width - width) / 2.0))
|
||||
return resized[:, :, top:top + height, left:left + width].float().div(255.0).unsqueeze(2)
|
||||
|
||||
|
||||
def _condition_tensor_to_vlm_image(pixel: torch.Tensor, patch_size: int) -> Image.Image:
|
||||
"""Convert the base condition tensor to the processor's exact RGB image."""
|
||||
frame = pixel[0, :, 0].detach().cpu().clamp(0, 1)
|
||||
array = frame.permute(1, 2, 0).mul(255).byte().numpy()
|
||||
image = Image.fromarray(array, mode="RGB")
|
||||
target_height, target_width = _smart_resize_image(image.height, image.width, patch_size * 2)
|
||||
return image.resize((target_width, target_height))
|
||||
|
||||
|
||||
def _compute_refiner_sigmas(
|
||||
@@ -78,6 +129,151 @@ class LingBotVideoInputValidationStage(InputValidationStage):
|
||||
return batch
|
||||
|
||||
|
||||
class LingBotVideoImageInputValidationStage(LingBotVideoInputValidationStage):
|
||||
"""Validate TI2V input and prepare its shared base-resolution condition."""
|
||||
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
"""Load one RGB image and preserve both raw and preprocessed representations."""
|
||||
batch = super().forward(batch, fastvideo_args)
|
||||
if not isinstance(batch.pil_image, Image.Image):
|
||||
raise ValueError("LingBot-Video TI2V requires image_path or one PIL image")
|
||||
if not isinstance(batch.height, int) or not isinstance(batch.width, int):
|
||||
raise TypeError("LingBot-Video TI2V requires integer height and width")
|
||||
batch.preprocessed_image = _preprocess_condition_image(batch.pil_image, batch.height, batch.width)
|
||||
return batch
|
||||
|
||||
|
||||
class LingBotVideoImagePromptEncodingStage(PipelineStage):
|
||||
"""Encode positive and negative TI2V prompts with the same Qwen3-VL image."""
|
||||
|
||||
performance_component_metric = "text_encoder_time_s"
|
||||
|
||||
def __init__(self, text_encoder, processor) -> None:
|
||||
self.text_encoder = text_encoder
|
||||
self.processor = processor
|
||||
|
||||
def _encode(
|
||||
self,
|
||||
text: str,
|
||||
image: Image.Image,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the released processor call and native compound encoder once."""
|
||||
encoder_config = fastvideo_args.pipeline_config.text_encoder_configs[0]
|
||||
processed_text = fastvideo_args.pipeline_config.preprocess_text_funcs[0](LINGBOT_VIDEO_IMAGE_TEMPLATE + text)
|
||||
target_device = get_local_torch_device()
|
||||
first_parameter = next(self.text_encoder.parameters(), None)
|
||||
encoder_device = first_parameter.device if first_parameter is not None else target_device
|
||||
moved_for_forward = False
|
||||
if (first_parameter is not None and not isinstance(first_parameter, DTensor)
|
||||
and encoder_device.type != target_device.type):
|
||||
self.text_encoder.to(target_device)
|
||||
encoder_device = target_device
|
||||
moved_for_forward = True
|
||||
inputs = self.processor(
|
||||
text=[processed_text],
|
||||
images=[image],
|
||||
videos=None,
|
||||
video_metadata=None,
|
||||
do_resize=False,
|
||||
truncation=True,
|
||||
max_length=encoder_config.text_len,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
).to(encoder_device)
|
||||
with set_forward_context(current_timestep=0, attn_metadata=None):
|
||||
outputs = self.text_encoder(**inputs, output_hidden_states=True)
|
||||
prompt_embeds, prompt_mask = fastvideo_args.pipeline_config.postprocess_text_funcs[0](
|
||||
outputs,
|
||||
inputs["attention_mask"],
|
||||
)
|
||||
if moved_for_forward and fastvideo_args.text_encoder_cpu_offload:
|
||||
self.text_encoder.to("cpu")
|
||||
return prompt_embeds.to(target_device), prompt_mask.to(target_device)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
"""Populate both CFG branches with image-conditioned prompt embeddings."""
|
||||
if not isinstance(batch.prompt, str) or not isinstance(batch.negative_prompt, str):
|
||||
raise TypeError("LingBot-Video TI2V requires string positive and negative prompts")
|
||||
if batch.preprocessed_image is None:
|
||||
raise ValueError("LingBot-Video TI2V prompt encoding requires the preprocessed image")
|
||||
vision_config = fastvideo_args.pipeline_config.text_encoder_configs[0].vision_config
|
||||
patch_size = int(vision_config["patch_size"])
|
||||
vlm_image = _condition_tensor_to_vlm_image(batch.preprocessed_image, patch_size)
|
||||
prompt_embeds, prompt_mask = self._encode(batch.prompt, vlm_image, fastvideo_args)
|
||||
batch.prompt_embeds.append(prompt_embeds)
|
||||
if batch.prompt_attention_mask is not None:
|
||||
batch.prompt_attention_mask.append(prompt_mask)
|
||||
if batch.do_classifier_free_guidance:
|
||||
negative_embeds, negative_mask = self._encode(batch.negative_prompt, vlm_image, fastvideo_args)
|
||||
if batch.negative_prompt_embeds is not None:
|
||||
batch.negative_prompt_embeds.append(negative_embeds)
|
||||
if batch.negative_attention_mask is not None:
|
||||
batch.negative_attention_mask.append(negative_mask)
|
||||
return batch
|
||||
|
||||
|
||||
class LingBotVideoImageLatentPreparationStage(PipelineStage):
|
||||
"""Encode the clean first frame before base diffusion noise is generated."""
|
||||
|
||||
performance_component_metric = "vae_encode_time_s"
|
||||
|
||||
def __init__(self, vae) -> None:
|
||||
self.vae = vae
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
"""Sample and normalize the released VAE posterior with the shared RNG."""
|
||||
if batch.preprocessed_image is None or not isinstance(batch.generator, torch.Generator):
|
||||
raise ValueError("LingBot-Video TI2V requires a condition image and device generator")
|
||||
device = get_local_torch_device()
|
||||
if isinstance(self.vae, torch.nn.Module):
|
||||
self.vae.to(device)
|
||||
pixels = batch.preprocessed_image.to(device=device, dtype=torch.float32)
|
||||
normalized = (pixels - 0.5) / 0.5
|
||||
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
|
||||
encoded = self.vae.encode(normalized)
|
||||
distribution = encoded.latent_dist if hasattr(encoded, "latent_dist") else encoded
|
||||
if not hasattr(distribution, "sample") or not callable(distribution.sample):
|
||||
raise TypeError("LingBot-Video TI2V requires a VAE posterior distribution")
|
||||
latents = distribution.sample(batch.generator)
|
||||
mean = torch.tensor(self.vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
|
||||
std_inverse = 1.0 / torch.tensor(
|
||||
self.vae.config.latents_std,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
).view(1, -1, 1, 1, 1)
|
||||
batch.image_latent = (latents.float() - mean) * std_inverse
|
||||
if getattr(fastvideo_args, "vae_cpu_offload", False):
|
||||
self.vae.to("cpu")
|
||||
return batch
|
||||
|
||||
|
||||
class LingBotVideoRefinerTextEncodingStage(PipelineStage):
|
||||
"""Replace image-conditioned embeddings with original text-only refiner input."""
|
||||
|
||||
performance_component_metric = "text_encoder_time_s"
|
||||
|
||||
def __init__(self, text_encoder, processor) -> None:
|
||||
self.text_stage = TextEncodingStage([text_encoder], [processor])
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
"""Re-encode only the positive prompt; refiner CFG clones it as zeros."""
|
||||
if not isinstance(batch.prompt, str):
|
||||
raise TypeError("LingBot-Video refiner requires one string prompt")
|
||||
embeds, masks = self.text_stage.encode_text(
|
||||
batch.prompt,
|
||||
fastvideo_args,
|
||||
encoder_index=0,
|
||||
return_attention_mask=True,
|
||||
)
|
||||
batch.prompt_embeds = embeds
|
||||
batch.prompt_attention_mask = masks
|
||||
return batch
|
||||
|
||||
|
||||
class LingBotVideoLatentPreparationStage(PipelineStage):
|
||||
"""Prepare fp32 latents in the released 4x temporal and 8x spatial geometry."""
|
||||
|
||||
@@ -108,6 +304,9 @@ class LingBotVideoLatentPreparationStage(PipelineStage):
|
||||
if tuple(batch.latents.shape) != shape:
|
||||
raise ValueError(f"supplied latent shape {tuple(batch.latents.shape)} does not match {shape}")
|
||||
batch.latents = batch.latents.to(device=device, dtype=torch.float32)
|
||||
if batch.image_latent is not None:
|
||||
condition_frames = batch.image_latent.shape[2]
|
||||
batch.latents[:, :, :condition_frames] = batch.image_latent.float()
|
||||
batch.raw_latent_shape = shape
|
||||
return batch
|
||||
|
||||
@@ -129,6 +328,32 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
|
||||
resized = F.interpolate(flat, size=(height, width), mode="bicubic", align_corners=False).clamp(0.0, 1.0)
|
||||
return resized.reshape(batch, frames, channels, height, width).permute(0, 2, 1, 3, 4).contiguous()
|
||||
|
||||
@staticmethod
|
||||
def _resize_clean_condition(
|
||||
image: Image.Image,
|
||||
target_height: int,
|
||||
target_width: int,
|
||||
geometry_height: int,
|
||||
geometry_width: int,
|
||||
) -> torch.Tensor:
|
||||
"""Center-crop to base aspect before the released bicubic refiner resize."""
|
||||
image = image.convert("RGB")
|
||||
image_width, image_height = image.size
|
||||
geometry_aspect = float(geometry_width) / float(geometry_height)
|
||||
image_aspect = float(image_width) / float(image_height)
|
||||
if image_aspect > geometry_aspect:
|
||||
crop_height = image_height
|
||||
crop_width = max(1, int(round(crop_height * geometry_aspect)))
|
||||
left, top = int(round((image_width - crop_width) / 2.0)), 0
|
||||
else:
|
||||
crop_width = image_width
|
||||
crop_height = max(1, int(round(crop_width / geometry_aspect)))
|
||||
left, top = 0, int(round((image_height - crop_height) / 2.0))
|
||||
crop = image.crop((left, top, left + crop_width, top + crop_height))
|
||||
crop = crop.resize((target_width, target_height), resample=Image.BICUBIC)
|
||||
frame = torch.from_numpy(np.asarray(crop, dtype=np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0)
|
||||
return frame.contiguous().permute(1, 0, 2, 3).unsqueeze(0)
|
||||
|
||||
def _encode_video(
|
||||
self,
|
||||
video: torch.Tensor,
|
||||
@@ -148,8 +373,12 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
|
||||
else:
|
||||
latents = encoded
|
||||
mean = torch.tensor(self.vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
|
||||
std = torch.tensor(self.vae.config.latents_std, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
|
||||
return ((latents.float() - mean) / std).to(latents)
|
||||
std_inverse = 1.0 / torch.tensor(
|
||||
self.vae.config.latents_std,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
).view(1, -1, 1, 1, 1)
|
||||
return ((latents.float() - mean) * std_inverse).to(latents)
|
||||
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
"""Prepare high-resolution refiner latents and its exact truncated sigma schedule."""
|
||||
@@ -167,6 +396,21 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
|
||||
generator = torch.Generator(device=device).manual_seed(batch.seed)
|
||||
resized = self._resize_video(batch.output, batch.height_sr, batch.width_sr)
|
||||
encoded = self._encode_video(resized, generator, device)
|
||||
if batch.preprocessed_image is not None:
|
||||
if not isinstance(batch.pil_image, Image.Image):
|
||||
raise TypeError("LingBot-Video TI2V refiner requires the original PIL condition")
|
||||
clean_pixels = self._resize_clean_condition(
|
||||
batch.pil_image,
|
||||
batch.height_sr,
|
||||
batch.width_sr,
|
||||
batch.height,
|
||||
batch.width,
|
||||
)
|
||||
clean_latent = self._encode_video(clean_pixels, generator, device)[:, :, :1].contiguous()
|
||||
encoded[:, :, :1] = clean_latent.to(encoded.dtype)
|
||||
batch.image_latent = clean_latent.float()
|
||||
else:
|
||||
batch.image_latent = None
|
||||
noise = torch.randn(encoded.shape, generator=generator, device=device, dtype=encoded.dtype)
|
||||
batch.latents = ((1.0 - batch.t_thresh) * encoded + batch.t_thresh * noise).float()
|
||||
batch.generator = generator
|
||||
@@ -285,6 +529,9 @@ class LingBotVideoDenoisingStage(PipelineStage):
|
||||
transformer_dtype = next(self.transformer.parameters()).dtype
|
||||
condition, condition_mask = self._prepare_conditions(batch, transformer_dtype, device)
|
||||
latents = batch.latents.to(device=device, dtype=torch.float32)
|
||||
if batch.image_latent is not None:
|
||||
condition_frames = batch.image_latent.shape[2]
|
||||
latents[:, :, :condition_frames] = batch.image_latent.float()
|
||||
do_cfg = self._uses_cfg(batch)
|
||||
negative = negative_mask = None
|
||||
if do_cfg and not batch.batch_cfg:
|
||||
@@ -335,6 +582,9 @@ class LingBotVideoDenoisingStage(PipelineStage):
|
||||
return_dict=False,
|
||||
generator=batch.generator,
|
||||
)[0].float()
|
||||
if batch.image_latent is not None:
|
||||
condition_frames = batch.image_latent.shape[2]
|
||||
latents[:, :, :condition_frames] = batch.image_latent.float()
|
||||
if batch.return_trajectory_latents:
|
||||
trajectory.append(latents.detach().cpu())
|
||||
trajectory_timesteps.append(timestep.detach().cpu())
|
||||
|
||||
+54
-14
@@ -29,7 +29,7 @@ from fastvideo.configs.pipelines.hunyuan15 import (Hunyuan15T2V480PConfig, Hunyu
|
||||
Hunyuan15SR1080PConfig)
|
||||
from fastvideo.configs.pipelines.hyworld import HYWorldConfig
|
||||
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5I2VConfig, Kandinsky5T2VConfig
|
||||
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
|
||||
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig, LingBotVideoTI2VConfig
|
||||
from fastvideo.configs.pipelines.lingbotworld import LingBotWorldI2V480PConfig
|
||||
from fastvideo.configs.pipelines.lingbotworld2 import LingBotWorld2CausalFastI2V480PConfig
|
||||
from fastvideo.configs.pipelines.longcat import LongCatT2V480PConfig
|
||||
@@ -129,6 +129,9 @@ class ConfigInfo:
|
||||
# Lets a model family map to a specific pipeline class by path/detector
|
||||
# (e.g. a T2V and I2V checkpoint that share a `_class_name`).
|
||||
pipeline_cls_name: str | None = None
|
||||
pipeline_config_cls_by_workload: dict[WorkloadType, type[PipelineConfig]] = dataclasses.field(default_factory=dict)
|
||||
default_preset_by_workload: dict[WorkloadType, str] = dataclasses.field(default_factory=dict)
|
||||
pipeline_cls_name_by_workload: dict[WorkloadType, str] = dataclasses.field(default_factory=dict)
|
||||
|
||||
|
||||
# The central registry mapping a model name to its configuration information
|
||||
@@ -150,6 +153,9 @@ def register_configs(
|
||||
model_family: str | None = None,
|
||||
default_preset: str | None = None,
|
||||
pipeline_cls_name: str | None = None,
|
||||
pipeline_config_cls_by_workload: dict[WorkloadType, type[PipelineConfig]] | None = None,
|
||||
default_preset_by_workload: dict[WorkloadType, str] | None = None,
|
||||
pipeline_cls_name_by_workload: dict[WorkloadType, str] | None = None,
|
||||
) -> None:
|
||||
"""Register config classes for a model family.
|
||||
|
||||
@@ -165,6 +171,9 @@ def register_configs(
|
||||
model_family=model_family,
|
||||
default_preset=default_preset,
|
||||
pipeline_cls_name=pipeline_cls_name,
|
||||
pipeline_config_cls_by_workload=pipeline_config_cls_by_workload or {},
|
||||
default_preset_by_workload=default_preset_by_workload or {},
|
||||
pipeline_cls_name_by_workload=pipeline_cls_name_by_workload or {},
|
||||
)
|
||||
|
||||
if hf_model_paths:
|
||||
@@ -522,27 +531,33 @@ def _register_configs() -> None:
|
||||
model_family="lingbotworld",
|
||||
default_preset="lingbotworld_i2v",
|
||||
)
|
||||
# LingBot-Video MoE T2V with the released second-stage refiner.
|
||||
# LingBot-Video MoE T2V/TI2V with the released second-stage refiner.
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
pipeline_config_cls=LingBotVideoT2VConfig,
|
||||
workload_types=(WorkloadType.T2V, ),
|
||||
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
|
||||
hf_model_paths=["FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers"],
|
||||
model_detectors=[lambda path: "lingbotvideomoepipeline" in path.lower()],
|
||||
model_family="lingbot_video",
|
||||
default_preset="lingbot_video_moe_refiner_t2v",
|
||||
pipeline_cls_name="LingBotVideoPipeline",
|
||||
pipeline_config_cls_by_workload={WorkloadType.I2V: LingBotVideoTI2VConfig},
|
||||
default_preset_by_workload={WorkloadType.I2V: "lingbot_video_moe_refiner_ti2v"},
|
||||
pipeline_cls_name_by_workload={WorkloadType.I2V: "LingBotVideoImageToVideoPipeline"},
|
||||
)
|
||||
# LingBot-Video Dense T2V
|
||||
# LingBot-Video Dense T2V/TI2V
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
pipeline_config_cls=LingBotVideoT2VConfig,
|
||||
workload_types=(WorkloadType.T2V, ),
|
||||
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
|
||||
hf_model_paths=["FastVideo/LingBot-Video-Dense-1.3B-Diffusers"],
|
||||
model_detectors=[lambda path: "lingbotvideodensepipeline" in path.lower()],
|
||||
model_family="lingbot_video",
|
||||
default_preset="lingbot_video_dense_t2v",
|
||||
pipeline_cls_name="LingBotVideoPipeline",
|
||||
pipeline_config_cls_by_workload={WorkloadType.I2V: LingBotVideoTI2VConfig},
|
||||
default_preset_by_workload={WorkloadType.I2V: "lingbot_video_dense_ti2v"},
|
||||
pipeline_cls_name_by_workload={WorkloadType.I2V: "LingBotVideoImageToVideoPipeline"},
|
||||
)
|
||||
|
||||
def _kandinsky5_detector(require: tuple[str, ...] = (), exclude: tuple[str, ...] = ()) -> Callable[[str], bool]:
|
||||
@@ -1205,12 +1220,14 @@ def get_model_info(
|
||||
config = maybe_download_model_index(model_path)
|
||||
|
||||
pipeline_name = config.get("_class_name")
|
||||
if config_info.pipeline_cls_name is not None:
|
||||
workload_pipeline_name = config_info.pipeline_cls_name_by_workload.get(workload_type)
|
||||
pinned_pipeline_name = workload_pipeline_name or config_info.pipeline_cls_name
|
||||
if pinned_pipeline_name is not None:
|
||||
# The resolved (path/detector-based) config pins the pipeline class,
|
||||
# e.g. an I2V checkpoint whose `_class_name` would otherwise resolve
|
||||
# to the T2V pipeline.
|
||||
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, config_info.pipeline_cls_name)
|
||||
pipeline_name = config_info.pipeline_cls_name
|
||||
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, pinned_pipeline_name)
|
||||
pipeline_name = pinned_pipeline_name
|
||||
|
||||
if pipeline_name is None:
|
||||
raise ValueError("Model config does not contain a _class_name attribute. "
|
||||
@@ -1224,16 +1241,27 @@ def get_model_info(
|
||||
return ModelInfo(
|
||||
pipeline_cls=pipeline_cls,
|
||||
sampling_param_cls=sampling_param_cls,
|
||||
pipeline_config_cls=config_info.pipeline_config_cls,
|
||||
pipeline_config_cls=config_info.pipeline_config_cls_by_workload.get(
|
||||
workload_type,
|
||||
config_info.pipeline_config_cls,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_pipeline_config_cls_from_name(pipeline_name_or_path: str) -> type[PipelineConfig]:
|
||||
def get_pipeline_config_cls_from_name(
|
||||
pipeline_name_or_path: str,
|
||||
workload_type: WorkloadType | str | None = None,
|
||||
) -> type[PipelineConfig]:
|
||||
"""Resolve a model's pipeline config, including workload-specific variants."""
|
||||
config_info = _get_config_info(pipeline_name_or_path, raise_on_missing=False)
|
||||
if config_info is None:
|
||||
raise ValueError(
|
||||
f"No match found for pipeline {pipeline_name_or_path}, please check the pipeline name or path.")
|
||||
return config_info.pipeline_config_cls
|
||||
if isinstance(workload_type, str):
|
||||
workload_type = WorkloadType.from_string(workload_type)
|
||||
if workload_type is None:
|
||||
return config_info.pipeline_config_cls
|
||||
return config_info.pipeline_config_cls_by_workload.get(workload_type, config_info.pipeline_config_cls)
|
||||
|
||||
|
||||
def get_sampling_param_cls_for_name(pipeline_name_or_path: str) -> Any | None:
|
||||
@@ -1331,15 +1359,22 @@ def get_model_family(model_path: str) -> str | None:
|
||||
return config_info.model_family
|
||||
|
||||
|
||||
def get_default_preset(model_path: str) -> str | None:
|
||||
def get_default_preset(model_path: str, workload_type: WorkloadType | str | None = None) -> str | None:
|
||||
"""Return the ``default_preset`` name for a model path."""
|
||||
config_info = _get_config_info(model_path, raise_on_missing=False)
|
||||
if config_info is None:
|
||||
return None
|
||||
if isinstance(workload_type, str):
|
||||
workload_type = WorkloadType.from_string(workload_type)
|
||||
if workload_type is not None:
|
||||
return config_info.default_preset_by_workload.get(workload_type, config_info.default_preset)
|
||||
return config_info.default_preset
|
||||
|
||||
|
||||
def get_preset_selection(model_path: str) -> tuple[str | None, str | None]:
|
||||
def get_preset_selection(
|
||||
model_path: str,
|
||||
workload_type: WorkloadType | str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(default_preset, model_family)`` for a model path.
|
||||
|
||||
Single-lookup variant of :func:`get_default_preset` +
|
||||
@@ -1349,7 +1384,12 @@ def get_preset_selection(model_path: str) -> tuple[str | None, str | None]:
|
||||
config_info = _get_config_info(model_path, raise_on_missing=False)
|
||||
if config_info is None:
|
||||
return None, None
|
||||
return config_info.default_preset, config_info.model_family
|
||||
if isinstance(workload_type, str):
|
||||
workload_type = WorkloadType.from_string(workload_type)
|
||||
preset = config_info.default_preset
|
||||
if workload_type is not None:
|
||||
preset = config_info.default_preset_by_workload.get(workload_type, preset)
|
||||
return preset, config_info.model_family
|
||||
|
||||
|
||||
def get_registered_model_paths() -> list[str]:
|
||||
|
||||
@@ -557,16 +557,18 @@ class TestLingBotVideoPresets:
|
||||
assert "Multiple models matched" not in caplog.text
|
||||
|
||||
def test_lingbot_video_presets_registered(self) -> None:
|
||||
"""Register both released LingBot-Video inference layouts."""
|
||||
"""Register T2V and TI2V presets for both released model layouts."""
|
||||
import fastvideo.registry # noqa: F401
|
||||
from fastvideo.api.sampling_param import SamplingParam
|
||||
from fastvideo.registry import get_preset_selection
|
||||
|
||||
presets = get_presets_for_family("lingbot_video")
|
||||
assert {preset.name
|
||||
for preset in presets} == {
|
||||
"lingbot_video_dense_t2v",
|
||||
"lingbot_video_moe_refiner_t2v",
|
||||
}
|
||||
assert {preset.name for preset in presets} == {
|
||||
"lingbot_video_dense_t2v",
|
||||
"lingbot_video_dense_ti2v",
|
||||
"lingbot_video_moe_refiner_t2v",
|
||||
"lingbot_video_moe_refiner_ti2v",
|
||||
}
|
||||
assert get_preset_selection("FastVideo/LingBot-Video-Dense-1.3B-Diffusers") == (
|
||||
"lingbot_video_dense_t2v",
|
||||
"lingbot_video",
|
||||
@@ -575,6 +577,21 @@ class TestLingBotVideoPresets:
|
||||
"lingbot_video_moe_refiner_t2v",
|
||||
"lingbot_video",
|
||||
)
|
||||
assert get_preset_selection(
|
||||
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
|
||||
"i2v",
|
||||
) == ("lingbot_video_dense_ti2v", "lingbot_video")
|
||||
assert get_preset_selection(
|
||||
"FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers",
|
||||
"i2v",
|
||||
) == ("lingbot_video_moe_refiner_ti2v", "lingbot_video")
|
||||
ti2v_sampling = SamplingParam.from_pretrained(
|
||||
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
|
||||
"i2v",
|
||||
)
|
||||
assert ti2v_sampling.height == 480
|
||||
assert ti2v_sampling.width == 832
|
||||
assert ti2v_sampling.batch_cfg is True
|
||||
|
||||
def test_moe_refiner_defaults(self) -> None:
|
||||
"""Expose the official base and 1080p refiner sampling defaults."""
|
||||
|
||||
@@ -15,9 +15,10 @@ import torch
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
|
||||
|
||||
LANGUAGE_PREFIX = "model.language_model."
|
||||
VISION_PREFIX = "model.visual."
|
||||
PASSTHROUGH_COMPONENTS = ("transformer", "vae", "scheduler")
|
||||
DENSE_PIPELINE_CLASS = "LingBotVideoDensePipeline"
|
||||
MOE_PIPELINE_CLASS = "LingBotVideoMoePipeline"
|
||||
@@ -38,11 +39,14 @@ def _copy_tree_with_weight_links(source: Path, destination: Path) -> None:
|
||||
|
||||
|
||||
def _fuse_language_shard(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
||||
"""Filter Qwen3-VL language tensors and fuse native QKV and gate/up weights."""
|
||||
"""Keep vision tensors and fuse native QKV and gate/up language weights."""
|
||||
converted = {
|
||||
name[len(LANGUAGE_PREFIX):]: tensor
|
||||
for name, tensor in state.items() if name.startswith(LANGUAGE_PREFIX)
|
||||
}
|
||||
converted.update(
|
||||
{f"visual.{name[len(VISION_PREFIX) :]}": tensor for name, tensor in state.items() if name.startswith(VISION_PREFIX)}
|
||||
)
|
||||
layer_ids = {int(name.split(".")[1]) for name in converted if name.startswith("layers.")}
|
||||
for layer_id in sorted(layer_ids):
|
||||
attention = f"layers.{layer_id}.self_attn"
|
||||
@@ -87,23 +91,30 @@ def _convert_text_encoder(source: Path, destination: Path) -> dict[str, str]:
|
||||
|
||||
|
||||
def _write_text_encoder_config(source: Path, destination: Path) -> dict:
|
||||
"""Flatten the compound Qwen3-VL config into FastVideo's text-model bucket."""
|
||||
"""Keep the compound Qwen3-VL architecture in FastVideo's encoder bucket."""
|
||||
official = json.loads((source / "config.json").read_text())
|
||||
text = dict(official["text_config"])
|
||||
valid = {item.name for item in fields(LingBotVideoQwen3VLTextConfig().arch_config)}
|
||||
valid = {item.name for item in fields(LingBotVideoQwen3VLConfig().arch_config)}
|
||||
config = {key: value for key, value in text.items() if key in valid}
|
||||
config.update({
|
||||
"_class_name": "LingBotVideoQwen3VLTextModel",
|
||||
"architectures": ["LingBotVideoQwen3VLTextModel"],
|
||||
"pad_token_id": 151643,
|
||||
"text_len": 37698,
|
||||
"output_hidden_states": True,
|
||||
"require_processor": True,
|
||||
"rope_scaling": None,
|
||||
"mrope_interleaved": True,
|
||||
"mrope_section": [24, 20, 20],
|
||||
})
|
||||
LingBotVideoQwen3VLTextConfig().update_model_arch(config)
|
||||
config.update(
|
||||
{
|
||||
"_class_name": "LingBotVideoQwen3VLModel",
|
||||
"architectures": ["LingBotVideoQwen3VLModel"],
|
||||
"vision_config": official["vision_config"],
|
||||
"image_token_id": official["image_token_id"],
|
||||
"video_token_id": official["video_token_id"],
|
||||
"vision_start_token_id": official["vision_start_token_id"],
|
||||
"vision_end_token_id": official["vision_end_token_id"],
|
||||
"pad_token_id": 151643,
|
||||
"text_len": 37698,
|
||||
"output_hidden_states": True,
|
||||
"require_processor": True,
|
||||
"rope_scaling": None,
|
||||
"mrope_interleaved": True,
|
||||
"mrope_section": [24, 20, 20],
|
||||
}
|
||||
)
|
||||
LingBotVideoQwen3VLConfig().update_model_arch(config)
|
||||
(destination / "config.json").write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
|
||||
for filename in ("generation_config.json", ):
|
||||
candidate = source / filename
|
||||
@@ -121,14 +132,14 @@ def _validate_transformer_config(path: Path) -> None:
|
||||
|
||||
|
||||
def _write_model_index(destination: Path, *, has_refiner: bool) -> None:
|
||||
"""Declare the native T2V components, including an optional refiner DiT."""
|
||||
"""Declare native T2V/TI2V components, including an optional refiner DiT."""
|
||||
model_index = {
|
||||
"_class_name": MOE_PIPELINE_CLASS if has_refiner else DENSE_PIPELINE_CLASS,
|
||||
"_diffusers_version": "0.39.0",
|
||||
"workload_type": "video-generation",
|
||||
"transformer": ["diffusers", "LingBotVideoTransformer3DModel"],
|
||||
"vae": ["diffusers", "AutoencoderKLWan"],
|
||||
"text_encoder": ["transformers", "LingBotVideoQwen3VLTextModel"],
|
||||
"text_encoder": ["transformers", "LingBotVideoQwen3VLModel"],
|
||||
"tokenizer": ["transformers", "Qwen3VLProcessor"],
|
||||
"scheduler": ["diffusers", "FlowUniPCMultistepScheduler"],
|
||||
}
|
||||
@@ -138,7 +149,7 @@ def _write_model_index(destination: Path, *, has_refiner: bool) -> None:
|
||||
|
||||
|
||||
def convert(source: Path, destination: Path) -> None:
|
||||
"""Create a Dense or MoE/refiner T2V layout without altering source assets."""
|
||||
"""Create a Dense or MoE/refiner T2V/TI2V layout without altering source assets."""
|
||||
required = (*PASSTHROUGH_COMPONENTS, "text_encoder", "processor")
|
||||
missing = [name for name in required if not (source / name).is_dir()]
|
||||
if missing:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exact Qwen3-VL image-conditioning parity for LingBot-Video TI2V."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
|
||||
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
|
||||
from fastvideo.configs.pipelines.lingbot_video import preprocess_lingbot_video_prompt
|
||||
from fastvideo.distributed.parallel_state import init_distributed_environment, initialize_model_parallel
|
||||
from fastvideo.models.encoders.lingbot_video import LingBotVideoQwen3VLModel
|
||||
from fastvideo.models.loader.component_loader import TextEncoderLoader
|
||||
from fastvideo.pipelines.basic.lingbot_video.stages import (
|
||||
LINGBOT_VIDEO_IMAGE_TEMPLATE,
|
||||
_condition_tensor_to_vlm_image,
|
||||
_preprocess_condition_image,
|
||||
)
|
||||
from tests.local_tests.lingbot_video.hf_assets import (
|
||||
FASTVIDEO_DENSE,
|
||||
OFFICIAL_DENSE,
|
||||
download_components,
|
||||
)
|
||||
|
||||
|
||||
def _load_native(device: torch.device, checkpoint: Path) -> LingBotVideoQwen3VLModel:
|
||||
"""Load the converted compound encoder through FastVideo's production loader."""
|
||||
args = SimpleNamespace(
|
||||
text_encoder_cpu_offload=False,
|
||||
override_text_encoder_quant=None,
|
||||
override_text_encoder_safetensors=None,
|
||||
pin_cpu_memory=False,
|
||||
)
|
||||
model = TextEncoderLoader().load_model(
|
||||
str(checkpoint / "text_encoder"),
|
||||
LingBotVideoQwen3VLConfig(),
|
||||
device,
|
||||
args,
|
||||
dtype="bf16",
|
||||
use_text_encoder_override=True,
|
||||
)
|
||||
return model.eval()
|
||||
|
||||
|
||||
def _assert_exact(name: str, expected: torch.Tensor, actual: torch.Tensor) -> None:
|
||||
"""Print exact drift metrics and require zero differing hidden-state values."""
|
||||
difference = (actual.float() - expected.float()).abs()
|
||||
print(
|
||||
f"{name}: equal={torch.equal(actual, expected)} "
|
||||
f"differing={torch.count_nonzero(actual != expected).item()} "
|
||||
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
|
||||
)
|
||||
assert torch.equal(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Qwen3-VL parity requires CUDA")
|
||||
def test_lingbot_video_ti2v_text_encoder_exact_parity() -> None:
|
||||
"""Require exact text-only, image-conditioned, and long-prompt hidden states."""
|
||||
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
|
||||
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
|
||||
device = torch.device("cuda")
|
||||
init_distributed_environment(world_size=1, rank=0, local_rank=0)
|
||||
initialize_model_parallel(tensor_model_parallel_size=1, sequence_model_parallel_size=1)
|
||||
local_official = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
|
||||
official_checkpoint = (
|
||||
Path(local_official)
|
||||
if local_official
|
||||
else download_components(OFFICIAL_DENSE, "text_encoder", "processor")
|
||||
)
|
||||
local_converted = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
|
||||
converted_checkpoint = Path(local_converted) if local_converted else download_components(FASTVIDEO_DENSE, "text_encoder")
|
||||
processor = AutoProcessor.from_pretrained(official_checkpoint / "processor", local_files_only=True)
|
||||
y, x = np.mgrid[0:941, 0:1672]
|
||||
image = Image.fromarray(
|
||||
np.stack((x % 256, y % 256, (x + y) % 256), axis=-1).astype(np.uint8),
|
||||
mode="RGB",
|
||||
)
|
||||
condition = _preprocess_condition_image(image, 480, 832)
|
||||
vlm_image = _condition_tensor_to_vlm_image(condition, patch_size=16)
|
||||
prompt = preprocess_lingbot_video_prompt(
|
||||
LINGBOT_VIDEO_IMAGE_TEMPLATE + "A runner and a white robot move toward the camera."
|
||||
)
|
||||
inputs = processor(
|
||||
text=[prompt],
|
||||
images=[vlm_image],
|
||||
videos=None,
|
||||
video_metadata=None,
|
||||
do_resize=False,
|
||||
truncation=True,
|
||||
max_length=37698,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
).to(device)
|
||||
official = Qwen3VLForConditionalGeneration.from_pretrained(
|
||||
official_checkpoint / "text_encoder",
|
||||
dtype=torch.bfloat16,
|
||||
attn_implementation="sdpa",
|
||||
local_files_only=True,
|
||||
).to(device).eval()
|
||||
native = _load_native(device, converted_checkpoint)
|
||||
with torch.no_grad():
|
||||
expected = official(**inputs, output_hidden_states=True, use_cache=False).hidden_states[-1]
|
||||
actual = native(**inputs, output_hidden_states=True).hidden_states[-1]
|
||||
_assert_exact("image_conditioned", expected, actual)
|
||||
text_inputs = processor(
|
||||
text=[preprocess_lingbot_video_prompt("A runner and a white robot move toward the camera.")],
|
||||
images=None,
|
||||
videos=None,
|
||||
do_resize=False,
|
||||
truncation=True,
|
||||
max_length=37698,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
).to(device)
|
||||
with torch.no_grad():
|
||||
expected_text = official(
|
||||
**text_inputs,
|
||||
output_hidden_states=True,
|
||||
use_cache=False,
|
||||
).hidden_states[-1]
|
||||
actual_text = native(
|
||||
**text_inputs,
|
||||
output_hidden_states=True,
|
||||
).hidden_states[-1]
|
||||
_assert_exact("text_only", expected_text, actual_text)
|
||||
long_prompt = " ".join(
|
||||
["A runner and a white robot move toward the camera under cherry blossoms."] * 160
|
||||
)
|
||||
long_inputs = processor(
|
||||
text=[preprocess_lingbot_video_prompt(LINGBOT_VIDEO_IMAGE_TEMPLATE + long_prompt)],
|
||||
images=[vlm_image],
|
||||
videos=None,
|
||||
video_metadata=None,
|
||||
do_resize=False,
|
||||
truncation=True,
|
||||
max_length=37698,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
).to(device)
|
||||
with torch.no_grad():
|
||||
expected_long = official(
|
||||
**long_inputs,
|
||||
output_hidden_states=True,
|
||||
use_cache=False,
|
||||
).hidden_states[-1]
|
||||
actual_long = native(
|
||||
**long_inputs,
|
||||
output_hidden_states=True,
|
||||
).hidden_states[-1]
|
||||
_assert_exact("long_image_conditioned", expected_long, actual_long)
|
||||
@@ -3,7 +3,7 @@
|
||||
## Summary
|
||||
|
||||
- model_family: `lingbot_video`
|
||||
- workload_types: T2V first, then T2I and TI2V (`WorkloadType.I2V`)
|
||||
- workload_types: T2V and TI2V (`WorkloadType.I2V`); T2I remains deferred
|
||||
- official_ref: `https://github.com/robbyant/lingbot-video` at `a638721cf2271804d02738b69f2ad788c4a559fc`
|
||||
- official_weights: `robbyant/lingbot-video-dense-1.3b`; `robbyant/lingbot-video-moe-30b-a3b`
|
||||
- fastvideo_weights: `FastVideo/LingBot-Video-Dense-1.3B-Diffusers`; `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers`
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## Current Phase
|
||||
|
||||
- phase: `t2v_complete`
|
||||
- phase: `ti2v_complete`
|
||||
- status: `complete`
|
||||
- owner: `pipeline`
|
||||
- last_updated: `2026-07-13`
|
||||
@@ -26,11 +26,14 @@
|
||||
| MoE DiT | DiT | port | `fastvideo/models/dits/lingbot_video.py` | pass | pass | full 48-block exact pass | none |
|
||||
| MoE DiT refiner | DiT | port | `fastvideo/models/dits/lingbot_video.py` | pass | pass | full 48-block exact pass | none |
|
||||
| Qwen3-VL text | encoder | reuse/port | `fastvideo/models/encoders/lingbot_video.py` | pass | pass | production exact pass | none |
|
||||
| Qwen3-VL vision | encoder | deferred | `fastvideo/models/encoders/lingbot_video.py` | deferred | not_started | outside current T2V scope | TI2V phase |
|
||||
| Qwen3-VL vision | encoder | reuse/port | `fastvideo/models/encoders/lingbot_video.py` | pass | pass | TI2V exact | none |
|
||||
| Wan VAE | VAE | reuse | `fastvideo/models/vaes/wanvae.py` | pass | passthrough | non-skip pass (`2/2`) | none |
|
||||
| Flow-UniPC | scheduler | reuse | `fastvideo/models/schedulers/` | pass | not_needed | exact schedule/update pass | none |
|
||||
| Base pipeline | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | Dense exact; MoE smoke pass | none |
|
||||
| Refiner pipeline | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | FSDP plus SP=8 smoke pass | none |
|
||||
| TI2V VAE encode | VAE | reuse | `fastvideo/models/vaes/wanvae.py` | pass | passthrough | exact condition-latent pass | none |
|
||||
| TI2V base | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | Dense and MoE exact pass | none |
|
||||
| TI2V refiner | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | FSDP plus SP=8 smoke pass | none |
|
||||
|
||||
## Checkpoint Conversion
|
||||
|
||||
@@ -51,10 +54,10 @@ What changes:
|
||||
|
||||
- Text encoder conversion: `scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`
|
||||
converts the official Qwen3-VL checkpoint into the format loaded by FastVideo's
|
||||
`LingBotVideoQwen3VLTextModel`. The official checkpoint is multimodal: it
|
||||
`LingBotVideoQwen3VLModel`. The official checkpoint is multimodal: it
|
||||
contains both a vision encoder and a language model. The current port supports
|
||||
text-to-video generation, so the converter keeps the language-model weights and
|
||||
omits the unused vision-encoder weights. Within each language-model layer,
|
||||
T2V and TI2V, so the converter preserves the vision encoder and converts the
|
||||
language-model weights. Within each language-model layer,
|
||||
"projection weights" are the learned matrices used by the attention and MLP
|
||||
computations. Qwen3-VL stores the attention matrices separately as `q_proj`,
|
||||
`k_proj`, and `v_proj`; FastVideo reads the same values stacked into one
|
||||
@@ -112,6 +115,19 @@ LingBot-Video implementation.
|
||||
| Complete MoE base transformer | Yes | Yes | `scripts/slurm/lingbot_video_moe_dit_parity.sbatch`, Slurm `1859114` |
|
||||
| Complete MoE refiner | Yes | Yes | `scripts/slurm/lingbot_video_moe_refiner_dit_parity.sbatch`, Slurm `1859145` |
|
||||
|
||||
#### Exact TI2V base-pipeline parity
|
||||
|
||||
Dense and MoE both match the official implementation exactly for processor
|
||||
inputs, Qwen3-VL embeddings, conditional and unconditional DiT outputs, the
|
||||
one-step result, all 40 denoising states, and decoded floating-point pixels.
|
||||
The test uses official TI2V example 4, sequential CFG, no refiner, and SDPA for
|
||||
Qwen3-VL on both sides. MP4 encoding is not part of this comparison.
|
||||
|
||||
- Baseline: `tests/local_tests/lingbot_video/generate_ti2v_reference_baseline.py`
|
||||
- Test: `tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py`
|
||||
- Dense result: `outputs/lingbot-video/validation/ti2v_exact_parity/fastvideo/dense/result.json`
|
||||
- MoE result: `outputs/lingbot-video/validation/ti2v_exact_parity/fastvideo/moe/result.json`
|
||||
|
||||
### Functional tests without an original-output comparison
|
||||
|
||||
These tests checked that FastVideo could load and run the port correctly. They
|
||||
@@ -249,7 +265,7 @@ NOTE: Component-level numerical parity was achieved. Full MoE/refiner end-to-end
|
||||
- Canonical Slurm `1859167` completed a 1920x1088, 121-frame, 24-FPS generation in 341.71 FastVideo seconds with a 63,450.65 MB rank-zero worker lifetime peak. Its bytes match superseded Slurm `1859144`.
|
||||
- The final targeted CPU/API regression passed 127 tests with 3 expected GPU-gated skips; targeted Ruff and tracked/untracked whitespace validation pass.
|
||||
|
||||
## Remaining T2V Gates
|
||||
## Remaining Gates
|
||||
|
||||
- Attach reviewer-visible official and FastVideo comparison videos.
|
||||
- T2I/TI2V and Qwen3-VL vision conditioning remain outside the completed T2V scope.
|
||||
- T2I remains outside the completed scope.
|
||||
|
||||
@@ -1,62 +1,84 @@
|
||||
# LingBot-Video Local Tests
|
||||
|
||||
This directory is the developer runbook for validating the LingBot-Video port in
|
||||
FastVideo. The current port covers text-to-video (T2V) inference for both released
|
||||
checkpoints:
|
||||
FastVideo. The current port covers text-to-video (T2V) and text-and-image-to-video
|
||||
(TI2V) inference for both released checkpoints:
|
||||
|
||||
- Dense: a single Dense transformer generates the video.
|
||||
- MoE/refiner: an MoE transformer generates a base video, then a second MoE
|
||||
transformer refines it at a larger resolution.
|
||||
- Dense: one Dense transformer generates the T2V or TI2V base video.
|
||||
- MoE/refiner: one MoE transformer generates the base video, then a second MoE
|
||||
transformer optionally refines it at a larger resolution.
|
||||
|
||||
These are local tests, which means they are run manually in this worktree and are
|
||||
not part of FastVideo's default CI suite. Prompt rewriting, automatic negative-prompt
|
||||
generation, T2I, TI2V are outside the current port.
|
||||
not part of FastVideo's default CI suite. For TI2V, the input image is used by
|
||||
Qwen3-VL for prompt conditioning and by the VAE as the clean first-frame latent.
|
||||
Prompt rewriting, automatic negative-prompt generation, and T2I are outside the
|
||||
current port.
|
||||
|
||||
## Current Testing Status
|
||||
|
||||
Comparing official Lingbot-Video impl vs. FastVideo impl.
|
||||
The results below compare the pinned official implementation with FastVideo.
|
||||
They use three distinct meanings of "pass":
|
||||
|
||||
| Area | Validation performed | Current result |
|
||||
| ---------------------------- | ---------------------------------------------------------- | ------------------------------------------- |
|
||||
| Qwen3-VL text-only encoder | Official versus FastVideo component comparison | Exact parity |
|
||||
| Flow-UniPC scheduler | Timesteps, sigmas, and deterministic update comparison | Exact parity |
|
||||
| Dense transformer | Full official versus FastVideo transformer comparison | Exact parity |
|
||||
| Wan VAE | Official versus FastVideo encode and decode comparison | Passes with `atol=0.05`, `rtol=0.05` |
|
||||
| Dense controlled pipeline | One- and two-step latent comparison | Exact parity |
|
||||
| Dense sequence parallelism | Two-GPU pipeline latent comparison with math SDPA | Exact parity |
|
||||
| MoE transformer block | Official versus FastVideo real-checkpoint block comparison | Exact parity |
|
||||
| MoE base transformer | Full 48-block sequential comparison | Exact parity |
|
||||
| MoE refiner transformer | Full 48-block sequential comparison | Exact parity |
|
||||
| MoE production pipeline | Full-size, 40-step batched-CFG frame comparison | Mean SSIM exceeds 0.78 |
|
||||
| MoE base pipeline parity | Full-size, 40-step sequential-CFG decoded-pixel comparison | Exact parity |
|
||||
| MoE base pipeline | Batched CFG over five frames and two denoising steps | Pass |
|
||||
| MoE plus refiner pipeline | Five-frame, two-step base-to-refiner handoff | Pass |
|
||||
| Final generated MP4 | Production generation and decode | Generation pass; no exact end-to-end parity |
|
||||
| T2I, TI2V, and vision branch | Not implemented by this T2V port | Outside current scope |
|
||||
- **Exact parity:** `torch.equal` is true and zero tensor values differ.
|
||||
- **Tolerance parity:** values are close within the test's stated threshold.
|
||||
- **Smoke pass:** the production path completes without error; this does not
|
||||
compare its tensors with the official implementation.
|
||||
|
||||
The production batched classifier-free guidance (CFG) comparison uses the
|
||||
original repository's packed FlashAttention-3 path and FastVideo's padded Torch
|
||||
SDPA path. Because those kernels have different bfloat16 rounding, this test
|
||||
compares decoded frames with a mean SSIM threshold of 0.78 instead of requiring
|
||||
identical pixels. Like-for-like sequential component and pipeline parity remains
|
||||
A pytest summary such as `2 passed` means that two test functions satisfied
|
||||
their own assertions. Use the table's **Test type** column to determine whether
|
||||
those assertions checked exact parity, tolerance parity, or only execution.
|
||||
|
||||
| Scope | Test type | Current result |
|
||||
| ------------------------------------------- | ------------------- | --------------------------------------------------------------------- |
|
||||
| CPU/API contracts | Unit and API | 87 passed; 6 GPU-only cases skipped in the CPU run |
|
||||
| T2V encoder, scheduler, and transformers | Exact parity | Exact at the tested component and controlled-pipeline boundaries |
|
||||
| T2V Wan VAE | Tolerance parity | Passes with `atol=0.05` and `rtol=0.05` |
|
||||
| T2V MoE production batched CFG | Tolerance parity | Full-size, 40-step decoded frames have mean SSIM above 0.78 |
|
||||
| T2V MoE base sequential CFG | Exact parity | Full-size, 40-step decoded pixels match exactly |
|
||||
| T2V production base and refiner workflows | Smoke | Pass |
|
||||
| TI2V Qwen3-VL text and vision branches | Exact parity | Zero differing hidden-state values |
|
||||
| TI2V clean-frame VAE encoding | Exact parity | Zero differing posterior, sample, and condition-latent values |
|
||||
| TI2V Dense base generation | Exact parity | Exact conditioning, 40-step trajectory, final latent, and pixels |
|
||||
| TI2V MoE base generation | Exact parity | Exact conditioning, 40-step trajectory, final latent, and pixels |
|
||||
| TI2V refiner preparation from one MP4 | Exact parity | Exact resized pixels, VAE latents, seeded noise, and initial latent |
|
||||
| TI2V MoE plus in-memory refiner production | Smoke | Pass with batched CFG, FSDP, and sequence parallelism |
|
||||
| Complete cross-repo refiner workflow | Not exact by design | Not claimed; the production handoff inputs intentionally differ |
|
||||
|
||||
The T2V production batched-CFG comparison uses the original repository's
|
||||
packed FlashAttention-3 path and FastVideo's padded Torch SDPA path. Because
|
||||
those kernels have different bfloat16 rounding, the test compares decoded
|
||||
frames with a mean SSIM threshold of 0.78 instead of requiring identical
|
||||
pixels. Like-for-like sequential component and pipeline parity remains
|
||||
bit-exact.
|
||||
|
||||
### One NOTE:
|
||||
Parity is established by pairing the official and FastVideo implementations of
|
||||
the text encoder, scheduler, Dense or MoE transformer, and VAE. A controlled
|
||||
Dense pipeline test also compares the latent tensor after denoising. There is no
|
||||
exact final-MP4 comparison for the complete MoE/refiner workflow because the two
|
||||
pipelines hand the base video to the refiner differently:
|
||||
### How Parity Is Interpreted
|
||||
|
||||
The exact TI2V base test uses official example 4 at 480x832, 121 frames, 40
|
||||
denoising steps, guidance 3, and seed 42. Both implementations run the positive
|
||||
and negative classifier-free-guidance branches as separate passes so their
|
||||
operation order is identical. The test compares conditioning, the clean-frame
|
||||
condition latent, initial noise, every denoising step, the final latent, decoded
|
||||
float pixels. Dense and MoE both match exactly. MP4 encoding is outside this
|
||||
strict comparison.
|
||||
|
||||
FastVideo's released presets use batched CFG for speed. That production setting
|
||||
is covered by a smoke test, not by the exact base comparison.
|
||||
|
||||
The full refiner workflows cannot receive identical inputs because they cross
|
||||
the base-to-refiner boundary differently:
|
||||
|
||||
```text
|
||||
Official repo: base decode -> save MP4 -> load MP4 -> resize and VAE-encode -> refine
|
||||
FastVideo: base decode -> keep tensor in memory -> resize and VAE-encode -> refine
|
||||
```
|
||||
|
||||
MP4 encoding is lossy, so the official workflow changes the pixels before the
|
||||
refiner receives them. FastVideo intentionally avoids that loss. The component
|
||||
parity tests therefore provide the meaningful numerical comparison, while the
|
||||
complete MoE/refiner test is a production smoke test.
|
||||
MP4 encoding is lossy, so the official production workflow changes the pixels
|
||||
before refinement while FastVideo intentionally preserves the decoded tensor.
|
||||
The exact refiner test removes that input difference by loading one shared
|
||||
official base MP4 in both implementations. From that shared input, resizing,
|
||||
clean-frame preparation, VAE encoding, seeded noise, and the initial refiner
|
||||
latent match exactly. The separate production smoke test verifies FastVideo's
|
||||
intended in-memory base-to-refiner workflow end to end.
|
||||
|
||||
## Setup: Required Workspace And Environment
|
||||
|
||||
@@ -117,9 +139,9 @@ token and no manually converted checkpoint directory are required.
|
||||
| Role | Repository | Pinned revision |
|
||||
| --------------------- | ------------------------------------------------------------ | ------------------------------------------ |
|
||||
| Official Dense | `robbyant/lingbot-video-dense-1.3b` | `f9789a7d9b4772a47aba62d4eb5282ddefd1da21` |
|
||||
| FastVideo Dense | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | `743ed04b96d77150d952eb08a59a56ee61b9bc95` |
|
||||
| FastVideo Dense | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | `efca07f906aa17a9b03380e2fc58ef17089b4e91` |
|
||||
| Official MoE/refiner | `robbyant/lingbot-video-moe-30b-a3b` | `f2e538f64afe00cc4ae674db2aeb52e2945edfd5` |
|
||||
| FastVideo MoE/refiner | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | `401dce84db5897cb950969e766410c8eadd4fbdf` |
|
||||
| FastVideo MoE/refiner | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | `575e01b56b39f8fc31a8029ab1789339de078663` |
|
||||
|
||||
Keep the cache inside the shared `fv-hub` workspace by setting `HF_HOME` before
|
||||
running pytest:
|
||||
@@ -139,10 +161,17 @@ official and FastVideo weights in the shared cache.
|
||||
Conversion is already reflected in the two public `FastVideo/*-Diffusers`
|
||||
repositories and is not a test setup step. Maintainers reproducing those
|
||||
packages can use
|
||||
`scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`. The script keeps
|
||||
the released transformer, VAE, and scheduler tensors; converts the text-only
|
||||
Qwen3-VL weights to FastVideo's fused layout; and maps the official MoE
|
||||
`refiner/` component to FastVideo's `transformer_2/` component.
|
||||
`scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`. The script:
|
||||
|
||||
- Keeps the released transformer, VAE, and scheduler tensors unchanged.
|
||||
- Fuses the Qwen3-VL language projections into FastVideo's native layout.
|
||||
- Preserves the Qwen3-VL vision tower in the same text-encoder component.
|
||||
- Copies the official multimodal processor into `tokenizer/`.
|
||||
- Maps the official MoE `refiner/` component to FastVideo's `transformer_2/`.
|
||||
|
||||
T2V and TI2V therefore use the same Dense or MoE Hugging Face repository. T2V
|
||||
uses only the language branch; TI2V also uses the vision branch. There are no
|
||||
separate TI2V checkpoint repositories.
|
||||
|
||||
### Setup Result
|
||||
|
||||
@@ -162,14 +191,15 @@ Run GPU tests only inside an allocated GPU job. The activation flags documented
|
||||
below are safety gates: without the required flag, pytest skips the expensive
|
||||
test instead of consuming a GPU accidentally.
|
||||
|
||||
| Test group | Required compute |
|
||||
| ---------------------------------- | ------------------------------------------------------------------- |
|
||||
| Routing, layout, and refiner logic | CPU |
|
||||
| Scheduler parity | CPU; its small pinned scheduler component downloads automatically |
|
||||
| Dense component and pipeline tests | 1 allocated CUDA GPU; prior acceptance runs used an H200 |
|
||||
| Dense sequence-parallel test | 2 allocated CUDA GPUs |
|
||||
| Full MoE DiT and base pipeline | 1 H200; official and FastVideo models are loaded sequentially |
|
||||
| Complete MoE/refiner pipeline | 8 H200 GPUs by default; FSDP and sequence parallelism are exercised |
|
||||
| Test group | Required compute |
|
||||
| ------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Routing, layout, and refiner logic | CPU |
|
||||
| Scheduler parity | CPU; its small pinned scheduler component downloads automatically |
|
||||
| Dense component and base-pipeline | 1 allocated CUDA GPU; acceptance runs used an H200 |
|
||||
| Qwen3-VL and clean-frame VAE parity | 1 allocated CUDA GPU; acceptance runs used an H200 |
|
||||
| Dense sequence-parallel test | 2 allocated CUDA GPUs |
|
||||
| Full MoE DiT and base pipeline | 1 H200; official and FastVideo models are loaded sequentially |
|
||||
| Production MoE TI2V plus refiner test | 8 H200 GPUs by default; FSDP and sequence parallelism are exercised |
|
||||
|
||||
Minimum GPU memory has not been established for the Dense tests. The H200 counts
|
||||
above describe the configuration used for the retained acceptance results.
|
||||
@@ -189,6 +219,7 @@ are outside this `lingbot_video/` directory.
|
||||
| `tests/local_tests/transformers/test_lingbot_video_moe.py` | MoE routing, expert weighting, dtype policy, and checkpoint surface |
|
||||
| `tests/local_tests/schedulers/test_lingbot_video_scheduler_parity.py` | Official and FastVideo scheduler timesteps, sigmas, and updates |
|
||||
| `tests/local_tests/pipelines/test_lingbot_video_refiner_stages.py` | Refiner resize, VAE encode/noise preparation, schedule, and loading |
|
||||
| `tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py` | T2V/TI2V registry, processor, validation, and latent contracts |
|
||||
| `tests/local_tests/models/test_fsdp_load_mixed_dtype.py` | Checkpoint dtype restoration; its nested-FSDP case is GPU-gated |
|
||||
|
||||
Run them from the FastVideo worktree:
|
||||
@@ -202,6 +233,7 @@ $PY -m pytest -q \
|
||||
tests/local_tests/transformers/test_lingbot_video_moe.py \
|
||||
tests/local_tests/schedulers/test_lingbot_video_scheduler_parity.py \
|
||||
tests/local_tests/pipelines/test_lingbot_video_refiner_stages.py \
|
||||
tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py \
|
||||
tests/local_tests/models/test_fsdp_load_mixed_dtype.py
|
||||
```
|
||||
|
||||
@@ -301,6 +333,75 @@ LINGBOT_VIDEO_PARITY_DETERMINISTIC=1 \
|
||||
tests/local_tests/pipelines/test_lingbot_video_pipeline_parity.py
|
||||
```
|
||||
|
||||
### TI2V GPU Tests
|
||||
|
||||
| Test path | What it checks |
|
||||
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `tests/local_tests/encoders/test_lingbot_video_ti2v_text_encoder_parity.py` | Exact text-only and image-conditioned Qwen3-VL hidden states |
|
||||
| `tests/local_tests/vaes/test_lingbot_video_ti2v_vae_encoder_parity.py` | Exact clean-frame VAE posterior parameters and seeded sample |
|
||||
| `tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py` | Dense/MoE exact one-step, 40-step, and decoded-pixel parity |
|
||||
| `tests/local_tests/vaes/test_lingbot_video_ti2v_refiner_vae_parity.py` | Exact refiner VAE, clean-frame, noise, and initial latent from one MP4 |
|
||||
| `tests/local_tests/pipelines/test_lingbot_video_ti2v_refiner_pipeline_smoke.py` | Production MoE TI2V, batched CFG, in-memory refinement, and decode |
|
||||
| `tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py` | TI2V routing, processor loading, image-latent contract, and T2V smoke |
|
||||
|
||||
Run the single-GPU component tests separately:
|
||||
|
||||
```bash
|
||||
cd "$FV_HUB/fastvideo-port-lingbot-video"
|
||||
PY="$FV_HUB/.venv/bin/python"
|
||||
|
||||
LINGBOT_VIDEO_RUN_GPU_TESTS=1 $PY -m pytest -v -s \
|
||||
tests/local_tests/encoders/test_lingbot_video_ti2v_text_encoder_parity.py
|
||||
LINGBOT_VIDEO_RUN_GPU_TESTS=1 $PY -m pytest -v -s \
|
||||
tests/local_tests/vaes/test_lingbot_video_ti2v_vae_encoder_parity.py
|
||||
```
|
||||
|
||||
The exact base-pipeline test first records the official output, then compares
|
||||
FastVideo with it. Run one variant at a time and set `VARIANT` to `dense` or
|
||||
`moe`:
|
||||
|
||||
```bash
|
||||
export CASE_DIR="$FV_HUB/lingbot-video-reference/assets/cases/ti2v/example_4"
|
||||
export PARITY_ROOT="$FV_HUB/fastvideo-port-lingbot-video/outputs/lingbot-video/validation/ti2v_exact_parity"
|
||||
export LINGBOT_QWEN_ATTN_IMPLEMENTATION=sdpa
|
||||
export FASTVIDEO_ATTENTION_BACKEND=TORCH_SDPA
|
||||
VARIANT=dense
|
||||
|
||||
$PY tests/local_tests/lingbot_video/generate_ti2v_reference_baseline.py \
|
||||
"$VARIANT" "$CASE_DIR" "$PARITY_ROOT/reference/$VARIANT"
|
||||
|
||||
LINGBOT_VIDEO_RUN_GPU_TESTS=1 \
|
||||
LINGBOT_VIDEO_TI2V_VARIANT="$VARIANT" \
|
||||
LINGBOT_VIDEO_TI2V_BASELINE_DIR="$PARITY_ROOT/reference/$VARIANT" \
|
||||
LINGBOT_VIDEO_TI2V_CASE_DIR="$CASE_DIR" \
|
||||
LINGBOT_VIDEO_TI2V_RESULT_DIR="$PARITY_ROOT/fastvideo/$VARIANT" \
|
||||
$PY -m pytest -v -s \
|
||||
tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py
|
||||
```
|
||||
|
||||
The SDPA settings keep the Qwen3-VL attention backend the same in both
|
||||
implementations. This removes kernel-rounding differences from the exact test.
|
||||
|
||||
The refiner VAE parity test requires one shared 121-frame base MP4. Both VAE
|
||||
implementations reload that same file, so MP4 compression is held constant:
|
||||
|
||||
```bash
|
||||
LINGBOT_VIDEO_RUN_GPU_TESTS=1 \
|
||||
LINGBOT_VIDEO_TI2V_SHARED_BASE_MP4=/path/to/original_base.mp4 \
|
||||
LINGBOT_VIDEO_TI2V_CONDITION_IMAGE="$FV_HUB/lingbot-video-reference/assets/cases/ti2v/example_4/first_frame.png" \
|
||||
$PY -m pytest -v -s \
|
||||
tests/local_tests/vaes/test_lingbot_video_ti2v_refiner_vae_parity.py
|
||||
```
|
||||
|
||||
Run the production base-plus-refiner smoke inside one eight-H200 allocation:
|
||||
|
||||
```bash
|
||||
LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS=1 \
|
||||
LINGBOT_VIDEO_REFINER_NUM_GPUS=8 \
|
||||
$PY -m pytest -v -s \
|
||||
tests/local_tests/pipelines/test_lingbot_video_ti2v_refiner_pipeline_smoke.py
|
||||
```
|
||||
|
||||
### MoE And Refiner GPU Tests
|
||||
|
||||
| Test path | What it checks |
|
||||
@@ -358,12 +459,14 @@ cd "$FV_HUB/fastvideo-port-lingbot-video"
|
||||
- An exact-parity failure means the paired official and FastVideo tensors differ;
|
||||
inspect the first reported mismatch rather than treating successful generation
|
||||
as a substitute.
|
||||
- A VAE failure means the error exceeded the explicit `0.05` absolute or relative
|
||||
tolerance used by the VAE tests.
|
||||
- A T2V VAE tolerance failure means the error exceeded `atol=0.05` or
|
||||
`rtol=0.05`. The TI2V VAE encoder tests instead require exact equality.
|
||||
- A smoke-test failure means FastVideo could not complete that production path; it
|
||||
does not by itself identify which component lost numerical parity.
|
||||
- Missing files under the required workspace paths are setup failures, not model
|
||||
correctness failures.
|
||||
|
||||
This README intentionally uses direct pytest commands. There are no retained
|
||||
LingBot-Video `launch_env.sh` or Slurm wrapper scripts in this worktree.
|
||||
This README intentionally uses direct pytest commands. The acceptance Slurm
|
||||
scripts, exact verdicts, and paired TI2V videos from the current validation are
|
||||
retained under `outputs/lingbot-video/quality_comparison_ti2v/`; they are
|
||||
evidence, not prerequisites for running the local tests.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Generate fixed official LingBot-Video TI2V tensors for exact parity tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from tests.local_tests.lingbot_video.hf_assets import (
|
||||
OFFICIAL_DENSE,
|
||||
OFFICIAL_MOE,
|
||||
download_components,
|
||||
materialize_component_view,
|
||||
)
|
||||
|
||||
os.environ.setdefault("DIFFUSERS_ATTN_BACKEND", "native")
|
||||
os.environ.setdefault("LINGBOT_QWEN_ATTN_IMPLEMENTATION", "sdpa")
|
||||
|
||||
HEIGHT = 480
|
||||
WIDTH = 832
|
||||
NUM_FRAMES = 121
|
||||
GUIDANCE_SCALE = 3.0
|
||||
FLOW_SHIFT = 3.0
|
||||
SEED = 42
|
||||
|
||||
|
||||
def _prompt(case_dir: Path) -> str:
|
||||
"""Serialize example 4 exactly as the official runner does."""
|
||||
sample = json.loads((case_dir / "prompt.json").read_text(encoding="utf-8"))
|
||||
return json.dumps(sample["caption"], ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _as_channel_first(value: Any) -> torch.Tensor:
|
||||
"""Normalize official latent or pixel output to a CPU float tensor."""
|
||||
tensor = value if torch.is_tensor(value) else torch.as_tensor(value)
|
||||
if tensor.ndim == 5 and tensor.shape[-1] == 3:
|
||||
tensor = tensor.permute(0, 4, 1, 2, 3)
|
||||
return tensor.detach().float().cpu()
|
||||
|
||||
|
||||
def _configure_backends() -> None:
|
||||
"""Use one deterministic CUDA policy for baseline generation and comparison."""
|
||||
torch.use_deterministic_algorithms(True)
|
||||
torch.backends.cudnn.enabled = False
|
||||
torch.backends.cuda.matmul.allow_tf32 = False
|
||||
torch.backends.cudnn.allow_tf32 = False
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
|
||||
|
||||
def _model_root(variant: str, scratch: Path) -> Path:
|
||||
"""Materialize the official base-generation component layout for one variant."""
|
||||
components = ("scheduler", "text_encoder", "processor", "transformer", "vae")
|
||||
if variant == "dense":
|
||||
return download_components(OFFICIAL_DENSE, *components)
|
||||
return materialize_component_view(OFFICIAL_MOE, scratch / "official_moe_base", *components)
|
||||
|
||||
|
||||
def _initial_latents() -> torch.Tensor:
|
||||
"""Create the fixed CPU noise tensor supplied to both implementations."""
|
||||
return torch.randn(
|
||||
(1, 16, (NUM_FRAMES - 1) // 4 + 1, HEIGHT // 8, WIDTH // 8),
|
||||
generator=torch.Generator(device="cpu").manual_seed(SEED),
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _run_reference(pipe: Any, case_dir: Path) -> dict[str, torch.Tensor]:
|
||||
"""Capture real one-step DiT calls, the 40-step trajectory, and decoded pixels."""
|
||||
from lingbot_video.pipeline_lingbot_video import DEFAULT_NEGATIVE_PROMPT
|
||||
|
||||
prompt = _prompt(case_dir)
|
||||
initial_latents = _initial_latents()
|
||||
with Image.open(case_dir / "first_frame.png") as image_file:
|
||||
image = image_file.convert("RGB")
|
||||
|
||||
dit_calls: list[dict[str, torch.Tensor]] = []
|
||||
text_encoder_calls: list[dict[str, torch.Tensor]] = []
|
||||
|
||||
def capture_text_encoder_call(
|
||||
_module: Any,
|
||||
_args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
_output: Any,
|
||||
) -> None:
|
||||
"""Retain the exact processor tensors supplied to both CFG text-encoder calls."""
|
||||
if len(text_encoder_calls) < 2:
|
||||
text_encoder_calls.append({
|
||||
name: value.detach().cpu()
|
||||
for name, value in kwargs.items()
|
||||
if torch.is_tensor(value)
|
||||
})
|
||||
|
||||
def capture_dit_call(_module: Any, args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> None:
|
||||
"""Retain the two sequential-CFG transformer calls from the one-step run."""
|
||||
output_tensor = output[0] if isinstance(output, tuple) else output.sample
|
||||
dit_calls.append({
|
||||
"hidden_states": args[0].detach().cpu(),
|
||||
"timestep": args[1].detach().cpu(),
|
||||
"encoder_hidden_states": args[2].detach().cpu(),
|
||||
"encoder_attention_mask": kwargs["encoder_attention_mask"].detach().cpu(),
|
||||
"output": output_tensor.detach().float().cpu(),
|
||||
})
|
||||
|
||||
text_encoder_hook = pipe.text_encoder.register_forward_hook(capture_text_encoder_call, with_kwargs=True)
|
||||
hook = pipe.transformer.register_forward_hook(capture_dit_call, with_kwargs=True)
|
||||
with torch.inference_mode():
|
||||
one_step = pipe(
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
negative_prompt=DEFAULT_NEGATIVE_PROMPT,
|
||||
height=HEIGHT,
|
||||
width=WIDTH,
|
||||
num_frames=NUM_FRAMES,
|
||||
num_inference_steps=1,
|
||||
guidance_scale=GUIDANCE_SCALE,
|
||||
shift=FLOW_SHIFT,
|
||||
generator=torch.Generator(device="cuda").manual_seed(SEED),
|
||||
latents=initial_latents.clone(),
|
||||
output_type="latent",
|
||||
batch_cfg=False,
|
||||
return_dict=True,
|
||||
)
|
||||
hook.remove()
|
||||
text_encoder_hook.remove()
|
||||
if len(dit_calls) != 2:
|
||||
raise AssertionError(f"expected two sequential-CFG DiT calls, got {len(dit_calls)}")
|
||||
if len(text_encoder_calls) != 2:
|
||||
raise AssertionError(f"expected two sequential-CFG text-encoder calls, got {len(text_encoder_calls)}")
|
||||
|
||||
trajectory: list[torch.Tensor] = []
|
||||
scheduler_step = pipe.scheduler.step
|
||||
|
||||
def capture_scheduler_step(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Retain every official scheduler output before clean-frame reinsertion."""
|
||||
result = scheduler_step(*args, **kwargs)
|
||||
trajectory.append(result[0].detach().float().cpu())
|
||||
return result
|
||||
|
||||
pipe.scheduler.step = capture_scheduler_step
|
||||
try:
|
||||
with torch.inference_mode():
|
||||
full = pipe(
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
negative_prompt=DEFAULT_NEGATIVE_PROMPT,
|
||||
height=HEIGHT,
|
||||
width=WIDTH,
|
||||
num_frames=NUM_FRAMES,
|
||||
num_inference_steps=40,
|
||||
guidance_scale=GUIDANCE_SCALE,
|
||||
shift=FLOW_SHIFT,
|
||||
generator=torch.Generator(device="cuda").manual_seed(SEED),
|
||||
latents=initial_latents.clone(),
|
||||
output_type="np",
|
||||
batch_cfg=False,
|
||||
return_dict=True,
|
||||
)
|
||||
finally:
|
||||
pipe.scheduler.step = scheduler_step
|
||||
if len(trajectory) != 40:
|
||||
raise AssertionError(f"expected 40 scheduler outputs, got {len(trajectory)}")
|
||||
conditional, unconditional = dit_calls
|
||||
guided = unconditional["output"] + GUIDANCE_SCALE * (conditional["output"] - unconditional["output"])
|
||||
return {
|
||||
"initial_latents": initial_latents,
|
||||
"condition_latent": conditional["hidden_states"][:, :, :1],
|
||||
"dit_hidden_states": conditional["hidden_states"],
|
||||
"dit_timestep": conditional["timestep"],
|
||||
"dit_prompt_embeds": conditional["encoder_hidden_states"],
|
||||
"dit_prompt_mask": conditional["encoder_attention_mask"],
|
||||
"dit_negative_embeds": unconditional["encoder_hidden_states"],
|
||||
"dit_negative_mask": unconditional["encoder_attention_mask"],
|
||||
"dit_conditional": conditional["output"],
|
||||
"dit_unconditional": unconditional["output"],
|
||||
"dit_guided": guided,
|
||||
"text_encoder_calls": text_encoder_calls,
|
||||
"one_step_latent": _as_channel_first(one_step.frames),
|
||||
"trajectory_before_condition": torch.stack(trajectory, dim=1),
|
||||
"pixels": _as_channel_first(full.frames),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load one pinned official variant and write its immutable parity baseline."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("variant", choices=("dense", "moe"))
|
||||
parser.add_argument("case_dir", type=Path)
|
||||
parser.add_argument("output_dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
_configure_backends()
|
||||
from lingbot_video.runner import _load_diffusers_pipe
|
||||
|
||||
dtype_map = {
|
||||
"default": torch.bfloat16,
|
||||
"transformer": torch.bfloat16,
|
||||
"text_encoder": torch.bfloat16,
|
||||
"vae": torch.float32,
|
||||
}
|
||||
with tempfile.TemporaryDirectory(dir=args.output_dir) as temporary:
|
||||
model_root = _model_root(args.variant, Path(temporary))
|
||||
pipe = _load_diffusers_pipe(model_root, dtype_map, mode="ti2v", transformer_subfolder="transformer")
|
||||
try:
|
||||
tensors = _run_reference(pipe, args.case_dir)
|
||||
finally:
|
||||
del pipe
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
torch.save(tensors, args.output_dir / "baseline.pt")
|
||||
image_sha256 = hashlib.sha256((args.case_dir / "first_frame.png").read_bytes()).hexdigest()
|
||||
manifest = {
|
||||
"variant": args.variant,
|
||||
"height": HEIGHT,
|
||||
"width": WIDTH,
|
||||
"num_frames": NUM_FRAMES,
|
||||
"steps": [1, 40],
|
||||
"guidance_scale": GUIDANCE_SCALE,
|
||||
"flow_shift": FLOW_SHIFT,
|
||||
"seed": SEED,
|
||||
"qwen_attention": os.environ["LINGBOT_QWEN_ATTN_IMPLEMENTATION"],
|
||||
"prompt": _prompt(args.case_dir),
|
||||
"image_sha256": image_sha256,
|
||||
"baseline": "baseline.pt",
|
||||
}
|
||||
(args.output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,11 +28,11 @@ OFFICIAL_MOE = HubModel(
|
||||
)
|
||||
FASTVIDEO_DENSE = HubModel(
|
||||
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
|
||||
"743ed04b96d77150d952eb08a59a56ee61b9bc95",
|
||||
"efca07f906aa17a9b03380e2fc58ef17089b4e91",
|
||||
)
|
||||
FASTVIDEO_MOE = HubModel(
|
||||
"FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers",
|
||||
"401dce84db5897cb950969e766410c8eadd4fbdf",
|
||||
"575e01b56b39f8fc31a8029ab1789339de078663",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,44 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from scripts.checkpoint_conversion import lingbot_video_to_diffusers as converter
|
||||
|
||||
|
||||
def test_converter_keeps_visual_tensors_beside_fused_language_tensors() -> None:
|
||||
"""Preserve the compound Qwen3-VL tower while fusing native language projections."""
|
||||
state = {
|
||||
"model.visual.patch_embed.proj.weight": torch.ones(2, 1),
|
||||
"model.language_model.layers.0.self_attn.q_proj.weight": torch.full((2, 2), 1.0),
|
||||
"model.language_model.layers.0.self_attn.k_proj.weight": torch.full((1, 2), 2.0),
|
||||
"model.language_model.layers.0.self_attn.v_proj.weight": torch.full((1, 2), 3.0),
|
||||
"model.language_model.layers.0.mlp.gate_proj.weight": torch.full((2, 2), 4.0),
|
||||
"model.language_model.layers.0.mlp.up_proj.weight": torch.full((2, 2), 5.0),
|
||||
}
|
||||
converted = converter._fuse_language_shard(state)
|
||||
assert torch.equal(converted["visual.patch_embed.proj.weight"], state["model.visual.patch_embed.proj.weight"])
|
||||
assert torch.equal(
|
||||
converted["layers.0.self_attn.qkv_proj.weight"],
|
||||
torch.cat(
|
||||
[
|
||||
state["model.language_model.layers.0.self_attn.q_proj.weight"],
|
||||
state["model.language_model.layers.0.self_attn.k_proj.weight"],
|
||||
state["model.language_model.layers.0.self_attn.v_proj.weight"],
|
||||
]
|
||||
),
|
||||
)
|
||||
assert torch.equal(
|
||||
converted["layers.0.mlp.gate_up_proj.weight"],
|
||||
torch.cat(
|
||||
[
|
||||
state["model.language_model.layers.0.mlp.gate_proj.weight"],
|
||||
state["model.language_model.layers.0.mlp.up_proj.weight"],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_source(tmp_path: Path, has_refiner: bool) -> Path:
|
||||
"""Create the minimal official component tree exercised by ``convert``."""
|
||||
source = tmp_path / "source"
|
||||
@@ -38,7 +72,7 @@ def _fake_convert_text_encoder(_source: Path, destination: Path) -> dict[str, st
|
||||
|
||||
def _fake_write_text_encoder_config(_source: Path, _destination: Path) -> dict:
|
||||
"""Return the metadata consumed by the converter's completion message."""
|
||||
return {"architectures": ["LingBotVideoQwen3VLTextModel"]}
|
||||
return {"architectures": ["LingBotVideoQwen3VLModel"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_refiner", [False, True])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Import, registry, stage-contract, and optional real Dense T2V smoke tests."""
|
||||
"""Import, registry, stage-contract, and optional real LingBot-Video smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,10 +18,12 @@ from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_DENSE, download_
|
||||
def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
|
||||
"""Verify exact class resolution, required modules, config, and official defaults."""
|
||||
from fastvideo.api.presets import get_preset
|
||||
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
|
||||
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig, LingBotVideoTI2VConfig
|
||||
from fastvideo.fastvideo_args import WorkloadType
|
||||
from fastvideo.pipelines.basic.lingbot_video.lingbot_video_pipeline import (
|
||||
EntryClass,
|
||||
LingBotVideoImageToVideoPipeline,
|
||||
LingBotVideoPipeline,
|
||||
)
|
||||
from fastvideo.registry import get_model_info, get_preset_selection
|
||||
@@ -31,7 +33,7 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
|
||||
"_class_name": "LingBotVideoDensePipeline",
|
||||
"_diffusers_version": "0.39.0",
|
||||
"scheduler": ["diffusers", "FlowUniPCMultistepScheduler"],
|
||||
"text_encoder": ["transformers", "LingBotVideoQwen3VLTextModel"],
|
||||
"text_encoder": ["transformers", "LingBotVideoQwen3VLModel"],
|
||||
"tokenizer": ["transformers", "Qwen3VLProcessor"],
|
||||
"transformer": ["diffusers", "LingBotVideoTransformer3DModel"],
|
||||
"vae": ["diffusers", "AutoencoderKLWan"],
|
||||
@@ -41,7 +43,7 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
|
||||
(model_dir / component).mkdir()
|
||||
(model_dir / "model_index.json").write_text(json.dumps(model_index), encoding="utf-8")
|
||||
assert model_index["_class_name"] == "LingBotVideoDensePipeline"
|
||||
assert EntryClass is LingBotVideoPipeline
|
||||
assert EntryClass == [LingBotVideoPipeline, LingBotVideoImageToVideoPipeline]
|
||||
assert set(model_index) >= set(LingBotVideoPipeline._required_config_modules)
|
||||
preset_name, family = get_preset_selection(str(model_dir))
|
||||
assert (preset_name, family) == ("lingbot_video_dense_t2v", "lingbot_video")
|
||||
@@ -60,6 +62,10 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
|
||||
info = get_model_info(str(model_dir), workload_type=WorkloadType.T2V)
|
||||
assert info.pipeline_cls is LingBotVideoPipeline
|
||||
assert info.pipeline_config_cls is LingBotVideoT2VConfig
|
||||
assert isinstance(LingBotVideoT2VConfig().text_encoder_configs[0], LingBotVideoQwen3VLConfig)
|
||||
i2v_info = get_model_info(str(model_dir), workload_type=WorkloadType.I2V)
|
||||
assert i2v_info.pipeline_cls is LingBotVideoImageToVideoPipeline
|
||||
assert i2v_info.pipeline_config_cls is LingBotVideoTI2VConfig
|
||||
|
||||
|
||||
def test_lingbot_video_prompt_crop_contract() -> None:
|
||||
@@ -81,6 +87,27 @@ def test_lingbot_video_prompt_crop_contract() -> None:
|
||||
assert torch.equal(embeds, hidden[:, 140:144])
|
||||
|
||||
|
||||
def test_lingbot_video_tokenizer_loader_uses_multimodal_processor(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Load the required Qwen3-VL processor even without processor_config.json."""
|
||||
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoTI2VConfig
|
||||
from fastvideo.models.loader import component_loader
|
||||
|
||||
expected = object()
|
||||
monkeypatch.setattr(
|
||||
component_loader.AutoProcessor,
|
||||
"from_pretrained",
|
||||
lambda *args, **kwargs: expected,
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
pipeline_config=LingBotVideoTI2VConfig(),
|
||||
trust_remote_code=False,
|
||||
)
|
||||
assert component_loader.TokenizerLoader().load(str(tmp_path), args) is expected
|
||||
|
||||
|
||||
def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Validate 4n+1 frames, multiples of 16, and fp32 latent geometry."""
|
||||
from fastvideo.pipelines.basic.lingbot_video import stages
|
||||
@@ -90,6 +117,7 @@ def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.Monkey
|
||||
transformer = SimpleNamespace(num_channels_latents=16)
|
||||
stage = stages.LingBotVideoLatentPreparationStage(transformer)
|
||||
supplied = torch.zeros(1, 16, 3, 8, 10, dtype=torch.bfloat16)
|
||||
condition = torch.full((1, 16, 1, 8, 10), 0.25, dtype=torch.float32)
|
||||
batch = ForwardBatch(
|
||||
data_type="video",
|
||||
prompt="fox",
|
||||
@@ -97,10 +125,48 @@ def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.Monkey
|
||||
height=64,
|
||||
width=80,
|
||||
latents=supplied,
|
||||
image_latent=condition,
|
||||
)
|
||||
result = stage.forward(batch, cast(Any, SimpleNamespace()))
|
||||
assert result.raw_latent_shape == (1, 16, 3, 8, 10)
|
||||
assert result.latents is not None and result.latents.dtype == torch.float32
|
||||
assert torch.equal(result.latents[:, :, :1], condition)
|
||||
|
||||
|
||||
def test_lingbot_video_image_latent_stage_accepts_native_vae_distribution(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Consume FastVideo's direct posterior return instead of requiring a wrapper."""
|
||||
from fastvideo.pipelines.basic.lingbot_video import stages
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
|
||||
class DirectDistribution:
|
||||
"""Expose the native Wan VAE sample contract used by the stage."""
|
||||
|
||||
def sample(self, generator: torch.Generator) -> torch.Tensor:
|
||||
del generator
|
||||
return torch.full((1, 16, 1, 2, 2), 0.5)
|
||||
|
||||
class NativeVAE:
|
||||
"""Return the posterior directly, as FastVideo's Wan VAE does."""
|
||||
|
||||
config = SimpleNamespace(latents_mean=[0.0] * 16, latents_std=[1.0] * 16)
|
||||
|
||||
def encode(self, _pixels: torch.Tensor) -> DirectDistribution:
|
||||
return DirectDistribution()
|
||||
|
||||
monkeypatch.setattr(stages, "get_local_torch_device", lambda: torch.device("cpu"))
|
||||
batch = ForwardBatch(
|
||||
data_type="video",
|
||||
preprocessed_image=torch.zeros(1, 3, 1, 16, 16),
|
||||
generator=torch.Generator().manual_seed(42),
|
||||
)
|
||||
result = stages.LingBotVideoImageLatentPreparationStage(NativeVAE()).forward(
|
||||
batch,
|
||||
cast(Any, SimpleNamespace(vae_cpu_offload=False)),
|
||||
)
|
||||
assert result.image_latent is not None
|
||||
assert torch.equal(result.image_latent, torch.full((1, 16, 1, 2, 2), 0.5))
|
||||
|
||||
|
||||
def test_lingbot_video_uses_released_vae_denormalization_arithmetic() -> None:
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exact one-step, 40-step, and decoded-pixel LingBot-Video TI2V parity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("LINGBOT_MOE_EXPERT_BACKEND", "grouped_mm")
|
||||
os.environ.setdefault("LINGBOT_MOE_PAD_BACKEND", "loop")
|
||||
os.environ.setdefault("LINGBOT_MOE_REORDER_BACKEND", "sort")
|
||||
os.environ.setdefault("LINGBOT_MOE_RESTORE_BACKEND", "scatter")
|
||||
|
||||
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
|
||||
from fastvideo.models.dits.lingbot_video import LingBotVideoTransformer3DModel
|
||||
from fastvideo.models.loader.fsdp_load import maybe_load_fsdp_model
|
||||
from tests.local_tests.lingbot_video.hf_assets import (
|
||||
FASTVIDEO_DENSE,
|
||||
FASTVIDEO_MOE,
|
||||
download_components,
|
||||
materialize_component_view,
|
||||
)
|
||||
|
||||
BASELINE_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_BASELINE_DIR")
|
||||
CASE_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_CASE_DIR")
|
||||
RESULT_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_RESULT_DIR")
|
||||
VARIANT = os.environ.get("LINGBOT_VIDEO_TI2V_VARIANT", "dense")
|
||||
|
||||
|
||||
def _configure_backends(_worker: Any | None = None) -> dict[str, bool]:
|
||||
"""Apply the baseline's deterministic CUDA policy in parent and worker processes."""
|
||||
torch.use_deterministic_algorithms(True)
|
||||
torch.backends.cudnn.enabled = False
|
||||
torch.backends.cuda.matmul.allow_tf32 = False
|
||||
torch.backends.cudnn.allow_tf32 = False
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
return {
|
||||
"deterministic": torch.are_deterministic_algorithms_enabled(),
|
||||
"cudnn": torch.backends.cudnn.enabled,
|
||||
"matmul_tf32": torch.backends.cuda.matmul.allow_tf32,
|
||||
"cudnn_tf32": torch.backends.cudnn.allow_tf32,
|
||||
"cudnn_sdp": torch.backends.cuda.cudnn_sdp_enabled(),
|
||||
}
|
||||
|
||||
|
||||
def _install_worker_dit_capture(worker: Any, capture_path: str) -> str:
|
||||
"""Capture the first production text-encoder and DiT calls."""
|
||||
pipeline = worker.pipeline
|
||||
pipeline.post_init()
|
||||
text_encoder = pipeline.get_module("text_encoder")
|
||||
transformer = pipeline.get_module("transformer")
|
||||
text_encoder_calls: list[dict[str, torch.Tensor]] = []
|
||||
dit_calls: list[dict[str, torch.Tensor]] = []
|
||||
|
||||
def capture_text_encoder_call(
|
||||
_module: Any,
|
||||
_args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
_output: Any,
|
||||
) -> None:
|
||||
"""Retain the exact processor tensors supplied to both CFG encoder calls."""
|
||||
if len(text_encoder_calls) < 2:
|
||||
text_encoder_calls.append({
|
||||
name: value.detach().cpu()
|
||||
for name, value in kwargs.items()
|
||||
if torch.is_tensor(value)
|
||||
})
|
||||
|
||||
def capture_call(_module: Any, args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> None:
|
||||
"""Write the two one-step sequential-CFG calls after both have completed."""
|
||||
if len(dit_calls) >= 2:
|
||||
return
|
||||
output_tensor = output[0] if isinstance(output, tuple) else output.sample
|
||||
dit_calls.append({
|
||||
"hidden_states": args[0].detach().float().cpu(),
|
||||
"timestep": args[1].detach().float().cpu(),
|
||||
"encoder_hidden_states": args[2].detach().cpu(),
|
||||
"encoder_attention_mask": kwargs["encoder_attention_mask"].detach().cpu(),
|
||||
"output": output_tensor.detach().float().cpu(),
|
||||
})
|
||||
if len(dit_calls) == 2:
|
||||
torch.save({"text_encoder": text_encoder_calls, "dit": dit_calls}, capture_path)
|
||||
|
||||
worker._lingbot_ti2v_text_encoder_capture_handle = text_encoder.register_forward_hook(
|
||||
capture_text_encoder_call,
|
||||
with_kwargs=True,
|
||||
)
|
||||
worker._lingbot_ti2v_capture_handle = transformer.register_forward_hook(capture_call, with_kwargs=True)
|
||||
return capture_path
|
||||
|
||||
|
||||
def _require_inputs() -> tuple[Path, Path, Path]:
|
||||
"""Require explicit baseline, example-input, and result directories."""
|
||||
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
|
||||
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on an allocated H200")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("LingBot-Video TI2V parity requires CUDA")
|
||||
if VARIANT not in {"dense", "moe"}:
|
||||
raise ValueError(f"unsupported TI2V parity variant: {VARIANT}")
|
||||
if not BASELINE_DIR or not CASE_DIR or not RESULT_DIR:
|
||||
raise ValueError(
|
||||
"set LINGBOT_VIDEO_TI2V_BASELINE_DIR, LINGBOT_VIDEO_TI2V_CASE_DIR, "
|
||||
"and LINGBOT_VIDEO_TI2V_RESULT_DIR"
|
||||
)
|
||||
return Path(BASELINE_DIR), Path(CASE_DIR), Path(RESULT_DIR)
|
||||
|
||||
|
||||
def _model_root(scratch: Path) -> Path:
|
||||
"""Materialize the converted base-generation components for the selected variant."""
|
||||
components = ("scheduler", "text_encoder", "tokenizer", "transformer", "vae")
|
||||
if VARIANT == "dense":
|
||||
return download_components(FASTVIDEO_DENSE, *components)
|
||||
return materialize_component_view(FASTVIDEO_MOE, scratch / "fastvideo_moe_base", *components)
|
||||
|
||||
|
||||
def _load_native_transformer(transformer_dir: Path, device: torch.device) -> torch.nn.Module:
|
||||
"""Strict-load one converted Dense or MoE DiT through FastVideo's production loader."""
|
||||
hf_config = json.loads((transformer_dir / "config.json").read_text(encoding="utf-8"))
|
||||
weight_files = sorted(str(path) for path in transformer_dir.glob("*.safetensors"))
|
||||
return maybe_load_fsdp_model(
|
||||
model_cls=LingBotVideoTransformer3DModel,
|
||||
init_params={"config": LingBotVideoConfig(), "hf_config": hf_config},
|
||||
weight_dir_list=weight_files,
|
||||
device=device,
|
||||
hsdp_replicate_dim=1,
|
||||
hsdp_shard_dim=1,
|
||||
default_dtype=torch.bfloat16,
|
||||
param_dtype=torch.bfloat16,
|
||||
reduce_dtype=torch.float32,
|
||||
strict=True,
|
||||
cpu_offload=False,
|
||||
fsdp_inference=False,
|
||||
training_mode=False,
|
||||
pin_cpu_memory=False,
|
||||
).eval()
|
||||
|
||||
|
||||
def _run_native_dit(model_root: Path, baseline: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
||||
"""Run the native DiT on the exact conditional and unconditional reference inputs."""
|
||||
device = torch.device("cuda:0")
|
||||
model = _load_native_transformer(model_root / "transformer", device)
|
||||
hidden_states = baseline["dit_hidden_states"].to(device)
|
||||
timestep = baseline["dit_timestep"].to(device)
|
||||
results: dict[str, torch.Tensor] = {}
|
||||
try:
|
||||
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16):
|
||||
for branch in ("conditional", "unconditional"):
|
||||
embeds = baseline[f"dit_{'prompt' if branch == 'conditional' else 'negative'}_embeds"].to(device)
|
||||
mask = baseline[f"dit_{'prompt' if branch == 'conditional' else 'negative'}_mask"].to(device)
|
||||
output = model(
|
||||
hidden_states,
|
||||
timestep,
|
||||
embeds,
|
||||
encoder_attention_mask=mask,
|
||||
return_dict=False,
|
||||
)[0]
|
||||
results[branch] = output.detach().float().cpu()
|
||||
results["guided"] = results["unconditional"] + 3.0 * (
|
||||
results["conditional"] - results["unconditional"]
|
||||
)
|
||||
return results
|
||||
finally:
|
||||
del model
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _as_channel_first(value: Any) -> torch.Tensor:
|
||||
"""Normalize FastVideo latent or pixel output to a CPU float tensor."""
|
||||
tensor = value if torch.is_tensor(value) else torch.as_tensor(value)
|
||||
if tensor.ndim == 5 and tensor.shape[-1] == 3:
|
||||
tensor = tensor.permute(0, 4, 1, 2, 3)
|
||||
return tensor.detach().float().cpu()
|
||||
|
||||
|
||||
def _run_fastvideo(
|
||||
model_root: Path,
|
||||
case_dir: Path,
|
||||
manifest: dict[str, Any],
|
||||
baseline: dict[str, torch.Tensor],
|
||||
initial_latents: torch.Tensor,
|
||||
result_dir: Path,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Run one-step and 40-step native TI2V calls without reloading the model."""
|
||||
from fastvideo import VideoGenerator
|
||||
from lingbot_video.pipeline_lingbot_video import DEFAULT_NEGATIVE_PROMPT
|
||||
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
str(model_root),
|
||||
workload_type="i2v",
|
||||
num_gpus=1,
|
||||
sp_size=1,
|
||||
use_fsdp_inference=False,
|
||||
dit_cpu_offload=False,
|
||||
dit_layerwise_offload=False,
|
||||
vae_cpu_offload=True,
|
||||
text_encoder_cpu_offload=True,
|
||||
pin_cpu_memory=False,
|
||||
output_type="np",
|
||||
refine_enabled=False,
|
||||
)
|
||||
common = {
|
||||
"prompt": manifest["prompt"],
|
||||
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
|
||||
"image_path": str(case_dir / "first_frame.png"),
|
||||
"output_path": str(result_dir),
|
||||
"save_video": False,
|
||||
"return_frames": True,
|
||||
"return_trajectory_latents": True,
|
||||
"height": manifest["height"],
|
||||
"width": manifest["width"],
|
||||
"num_frames": manifest["num_frames"],
|
||||
"guidance_scale": manifest["guidance_scale"],
|
||||
"batch_cfg": False,
|
||||
"seed": manifest["seed"],
|
||||
}
|
||||
try:
|
||||
states = generator.executor.collective_rpc(_configure_backends)
|
||||
assert all(state == _configure_backends() for state in states)
|
||||
capture_path = result_dir / "pipeline_dit_calls.pt"
|
||||
generator.executor.collective_rpc(
|
||||
_install_worker_dit_capture,
|
||||
kwargs={"capture_path": str(capture_path)},
|
||||
)
|
||||
one_step = cast(
|
||||
dict[str, Any],
|
||||
generator.generate_video(num_inference_steps=1, latents=initial_latents.clone(), **common),
|
||||
)
|
||||
calls = torch.load(capture_path, map_location="cpu", weights_only=True)
|
||||
for index, branch in enumerate(("conditional", "unconditional")):
|
||||
expected_inputs = baseline["text_encoder_calls"][index]
|
||||
actual_inputs = calls["text_encoder"][index]
|
||||
assert actual_inputs.keys() == expected_inputs.keys()
|
||||
for name in actual_inputs:
|
||||
metrics = _metrics(actual_inputs[name], expected_inputs[name])
|
||||
print(f"pipeline_{branch}_text_encoder_{name}={json.dumps(metrics, sort_keys=True)}")
|
||||
assert metrics["equal"]
|
||||
for index, branch in enumerate(("conditional", "unconditional")):
|
||||
expected_prefix = "prompt" if branch == "conditional" else "negative"
|
||||
comparisons = {
|
||||
f"pipeline_{branch}_hidden_states": (calls["dit"][index]["hidden_states"], baseline["dit_hidden_states"]),
|
||||
f"pipeline_{branch}_timestep": (calls["dit"][index]["timestep"], baseline["dit_timestep"]),
|
||||
f"pipeline_{branch}_embeds": (
|
||||
calls["dit"][index]["encoder_hidden_states"],
|
||||
baseline[f"dit_{expected_prefix}_embeds"],
|
||||
),
|
||||
f"pipeline_{branch}_mask": (
|
||||
calls["dit"][index]["encoder_attention_mask"],
|
||||
baseline[f"dit_{expected_prefix}_mask"],
|
||||
),
|
||||
f"pipeline_{branch}_output": (calls["dit"][index]["output"], baseline[f"dit_{branch}"]),
|
||||
}
|
||||
for name, (actual, expected) in comparisons.items():
|
||||
metrics = _metrics(actual, expected)
|
||||
print(f"{name}={json.dumps(metrics, sort_keys=True)}")
|
||||
assert metrics["equal"]
|
||||
full = cast(
|
||||
dict[str, Any],
|
||||
generator.generate_video(num_inference_steps=40, latents=initial_latents.clone(), **common),
|
||||
)
|
||||
one_trajectory = cast(torch.Tensor, one_step["trajectory"])
|
||||
full_trajectory = cast(torch.Tensor, full["trajectory"])
|
||||
return (
|
||||
one_trajectory[:, 0].detach().float().cpu(),
|
||||
full_trajectory.detach().float().cpu(),
|
||||
_as_channel_first(full["samples"]),
|
||||
)
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
def _metrics(actual: torch.Tensor, expected: torch.Tensor) -> dict[str, int | float | bool]:
|
||||
"""Return exact-equality and absolute-drift metrics for one tensor boundary."""
|
||||
difference = (actual - expected).abs()
|
||||
return {
|
||||
"equal": torch.equal(actual, expected),
|
||||
"differing": int(torch.count_nonzero(actual != expected).item()),
|
||||
"max_abs": float(difference.max().item()),
|
||||
"mean_abs": float(difference.float().mean().item()),
|
||||
}
|
||||
|
||||
|
||||
def test_lingbot_video_ti2v_exact_parity() -> None:
|
||||
"""Require exact actual-input DiT, one-step, 40-step, and pixel parity."""
|
||||
baseline_dir, case_dir, result_dir = _require_inputs()
|
||||
result_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = json.loads((baseline_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
if manifest["variant"] != VARIANT:
|
||||
raise ValueError(f"baseline variant {manifest['variant']} does not match requested {VARIANT}")
|
||||
image_sha256 = hashlib.sha256((case_dir / "first_frame.png").read_bytes()).hexdigest()
|
||||
if image_sha256 != manifest["image_sha256"]:
|
||||
raise ValueError("condition image does not match the reference baseline")
|
||||
baseline = torch.load(baseline_dir / manifest["baseline"], map_location="cpu", weights_only=True)
|
||||
_configure_backends()
|
||||
with tempfile.TemporaryDirectory(dir=result_dir) as temporary:
|
||||
model_root = _model_root(Path(temporary))
|
||||
dit = _run_native_dit(model_root, baseline)
|
||||
results = {
|
||||
"dit_conditional": _metrics(dit["conditional"], baseline["dit_conditional"]),
|
||||
"dit_unconditional": _metrics(dit["unconditional"], baseline["dit_unconditional"]),
|
||||
"dit_guided": _metrics(dit["guided"], baseline["dit_guided"]),
|
||||
}
|
||||
for name, metrics in results.items():
|
||||
print(f"{name}={json.dumps(metrics, sort_keys=True)}")
|
||||
assert metrics["equal"]
|
||||
one_step, trajectory, pixels = _run_fastvideo(
|
||||
model_root,
|
||||
case_dir,
|
||||
manifest,
|
||||
baseline,
|
||||
baseline["initial_latents"],
|
||||
result_dir,
|
||||
)
|
||||
conditioned_reference = baseline["trajectory_before_condition"].clone()
|
||||
condition_frames = baseline["condition_latent"].shape[2]
|
||||
conditioned_reference[:, :, :, :condition_frames] = baseline["condition_latent"][:, None]
|
||||
results.update({
|
||||
"one_step_latent": _metrics(one_step, baseline["one_step_latent"]),
|
||||
"forty_step_trajectory": _metrics(trajectory, conditioned_reference),
|
||||
"decoded_pixels": _metrics(pixels, baseline["pixels"]),
|
||||
})
|
||||
(result_dir / "result.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
for name in ("one_step_latent", "forty_step_trajectory", "decoded_pixels"):
|
||||
print(f"{name}={json.dumps(results[name], sort_keys=True)}")
|
||||
assert results[name]["equal"]
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Multi-GPU production smoke for LingBot-Video TI2V plus refinement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_MOE, download_components
|
||||
|
||||
|
||||
def test_lingbot_video_moe_ti2v_refiner_pipeline_smoke(tmp_path: Path) -> None:
|
||||
"""Run one conditioned frame through image prompting, both VAEs, and both MoE DiTs."""
|
||||
if os.environ.get("LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS") != "1":
|
||||
pytest.skip("Set LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS=1 on a scheduled H200 node.")
|
||||
required_gpus = int(os.environ.get("LINGBOT_VIDEO_REFINER_NUM_GPUS", "8"))
|
||||
if not torch.cuda.is_available() or torch.cuda.device_count() < required_gpus:
|
||||
pytest.skip(f"LingBot-Video TI2V refiner smoke requires {required_gpus} CUDA devices.")
|
||||
local_checkpoint = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
|
||||
model_dir = Path(local_checkpoint) if local_checkpoint else download_components(
|
||||
FASTVIDEO_MOE,
|
||||
"scheduler",
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"transformer",
|
||||
"transformer_2",
|
||||
"vae",
|
||||
)
|
||||
image_path = tmp_path / "condition.png"
|
||||
Image.fromarray(np.full((64, 96, 3), 127, dtype=np.uint8), mode="RGB").save(image_path)
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
str(model_dir),
|
||||
workload_type="i2v",
|
||||
num_gpus=required_gpus,
|
||||
sp_size=required_gpus,
|
||||
use_fsdp_inference=True,
|
||||
refine_enabled=True,
|
||||
dit_cpu_offload=False,
|
||||
dit_layerwise_offload=False,
|
||||
vae_cpu_offload=False,
|
||||
text_encoder_cpu_offload=True,
|
||||
pin_cpu_memory=False,
|
||||
)
|
||||
try:
|
||||
result = generator.generate_video(
|
||||
prompt="A red fox runs through fresh snow at sunrise.",
|
||||
image_path=str(image_path),
|
||||
output_path=str(tmp_path),
|
||||
save_video=False,
|
||||
return_frames=True,
|
||||
height=32,
|
||||
width=32,
|
||||
height_sr=64,
|
||||
width_sr=64,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
num_inference_steps_sr=1,
|
||||
guidance_scale=3.0,
|
||||
guidance_scale_2=3.0,
|
||||
t_thresh=0.85,
|
||||
batch_cfg=True,
|
||||
seed=42,
|
||||
)
|
||||
finally:
|
||||
generator.shutdown()
|
||||
samples = cast(dict[str, Any], result)["samples"]
|
||||
assert torch.is_tensor(samples)
|
||||
assert tuple(samples.shape) == (1, 3, 1, 64, 64)
|
||||
assert torch.isfinite(samples).all()
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exact LingBot-Video TI2V refiner-state parity from one shared MP4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers import AutoencoderKLWan as OfficialAutoencoderKLWan
|
||||
from PIL import Image
|
||||
from safetensors.torch import load_file
|
||||
from torch.nn.attention import SDPBackend, sdpa_kernel
|
||||
|
||||
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
|
||||
from fastvideo.models.vaes.wanvae import AutoencoderKLWan as FastVideoAutoencoderKLWan
|
||||
from fastvideo.pipelines.basic.lingbot_video.stages import LingBotVideoRefinerPreparationStage
|
||||
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_MOE, OFFICIAL_MOE, download_components
|
||||
|
||||
|
||||
def _load_native(checkpoint: Path, device: torch.device) -> FastVideoAutoencoderKLWan:
|
||||
"""Strict-load the released full VAE into FastVideo's native implementation."""
|
||||
vae_dir = checkpoint / "vae"
|
||||
raw_config = json.loads((vae_dir / "config.json").read_text())
|
||||
config = WanVAEConfig()
|
||||
config.update_model_arch({key: value for key, value in raw_config.items() if not key.startswith("_")})
|
||||
config.load_encoder = True
|
||||
config.load_decoder = True
|
||||
model = FastVideoAutoencoderKLWan(config)
|
||||
model.load_state_dict(load_file(vae_dir / "diffusion_pytorch_model.safetensors"), strict=True)
|
||||
return model.to(device=device, dtype=torch.float32).eval()
|
||||
|
||||
|
||||
def _encode(vae: torch.nn.Module, video: torch.Tensor, generator: torch.Generator) -> torch.Tensor:
|
||||
"""Run the released refiner VAE encode and DiT-space normalization boundary."""
|
||||
device = next(vae.parameters()).device
|
||||
normalized = video.to(device=device, dtype=torch.float32).mul(2.0).sub(1.0)
|
||||
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16), sdpa_kernel(SDPBackend.MATH):
|
||||
encoded = vae.encode(normalized)
|
||||
distribution = encoded.latent_dist if hasattr(encoded, "latent_dist") else encoded
|
||||
latents = distribution.sample(generator)
|
||||
mean = torch.tensor(vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
|
||||
std_inverse = 1.0 / torch.tensor(
|
||||
vae.config.latents_std,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
).view(1, -1, 1, 1, 1)
|
||||
return ((latents.float() - mean) * std_inverse).to(latents)
|
||||
|
||||
|
||||
def _prepare_state(
|
||||
vae: torch.nn.Module,
|
||||
video: torch.Tensor,
|
||||
clean_frame: torch.Tensor,
|
||||
device: torch.device,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Encode video then clean frame and create the seeded TI2V refiner state."""
|
||||
generator = torch.Generator(device=device).manual_seed(42)
|
||||
encoded = _encode(vae, video, generator)
|
||||
clean_latent = _encode(vae, clean_frame, generator)[:, :, :1].contiguous()
|
||||
encoded[:, :, :1] = clean_latent.to(encoded.dtype)
|
||||
noise = torch.randn(encoded.shape, generator=generator, device=device, dtype=encoded.dtype)
|
||||
initial = (1.0 - 0.85) * encoded + 0.85 * noise
|
||||
return {
|
||||
"encoded": encoded.detach().cpu(),
|
||||
"clean_latent": clean_latent.detach().cpu(),
|
||||
"noise": noise.detach().cpu(),
|
||||
"initial_latent": initial.detach().cpu(),
|
||||
}
|
||||
|
||||
|
||||
def _report_exact(name: str, expected: torch.Tensor, actual: torch.Tensor) -> None:
|
||||
"""Print exact drift metrics and require zero differing values."""
|
||||
difference = (actual.float() - expected.float()).abs()
|
||||
print(
|
||||
f"{name}: equal={torch.equal(actual, expected)} "
|
||||
f"differing={torch.count_nonzero(actual != expected).item()} "
|
||||
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
|
||||
)
|
||||
assert torch.equal(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Wan VAE refiner parity requires CUDA")
|
||||
def test_lingbot_video_ti2v_refiner_state_exact_parity() -> None:
|
||||
"""Require exact official/native refiner initialization from one reloaded MP4."""
|
||||
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
|
||||
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
|
||||
shared_mp4 = os.environ.get("LINGBOT_VIDEO_TI2V_SHARED_BASE_MP4")
|
||||
condition_image = os.environ.get("LINGBOT_VIDEO_TI2V_CONDITION_IMAGE")
|
||||
if not shared_mp4 or not condition_image:
|
||||
pytest.skip("set the shared base MP4 and clean condition image paths")
|
||||
from decord import VideoReader, cpu
|
||||
from lingbot_video.utils import (
|
||||
compute_training_aligned_indices,
|
||||
load_first_frame_condition_tensor,
|
||||
load_refiner_video_tensor,
|
||||
)
|
||||
|
||||
expected_video, metadata = load_refiner_video_tensor(shared_mp4, 1088, 1920, sample_fps=24, vae_tc=4)
|
||||
assert metadata["sample_frame"] == 121
|
||||
reader = VideoReader(shared_mp4, ctx=cpu(0))
|
||||
indices = compute_training_aligned_indices(len(reader), metadata["sample_frame"])
|
||||
raw_frames = torch.from_numpy(reader.get_batch(indices).asnumpy()).permute(0, 3, 1, 2).float().div(255.0)
|
||||
raw_video = raw_frames.permute(1, 0, 2, 3).unsqueeze(0).contiguous()
|
||||
actual_video = LingBotVideoRefinerPreparationStage._resize_video(raw_video, 1088, 1920)
|
||||
_report_exact("resized_video_pixels", expected_video, actual_video)
|
||||
expected_clean = load_first_frame_condition_tensor(condition_image, 1088, 1920, 480, 832)
|
||||
with Image.open(condition_image) as image:
|
||||
actual_clean = LingBotVideoRefinerPreparationStage._resize_clean_condition(
|
||||
image,
|
||||
1088,
|
||||
1920,
|
||||
480,
|
||||
832,
|
||||
)
|
||||
_report_exact("clean_condition_pixels", expected_clean, actual_clean)
|
||||
device = torch.device("cuda")
|
||||
official_path = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
|
||||
official_checkpoint = Path(official_path) if official_path else download_components(OFFICIAL_MOE, "vae")
|
||||
converted_path = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
|
||||
converted_checkpoint = Path(converted_path) if converted_path else download_components(FASTVIDEO_MOE, "vae")
|
||||
official = OfficialAutoencoderKLWan.from_pretrained(
|
||||
official_checkpoint / "vae",
|
||||
torch_dtype=torch.float32,
|
||||
local_files_only=True,
|
||||
).to(device).eval()
|
||||
expected = _prepare_state(official, expected_video, expected_clean, device)
|
||||
del official
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
native = _load_native(converted_checkpoint, device)
|
||||
actual = _prepare_state(native, actual_video, actual_clean, device)
|
||||
for name in ("encoded", "clean_latent", "noise", "initial_latent"):
|
||||
_report_exact(name, expected[name], actual[name])
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exact clean-frame VAE encoder parity for LingBot-Video TI2V."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers import AutoencoderKLWan as OfficialAutoencoderKLWan
|
||||
from PIL import Image
|
||||
from safetensors.torch import load_file
|
||||
|
||||
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
|
||||
from fastvideo.models.vaes.wanvae import AutoencoderKLWan as FastVideoAutoencoderKLWan
|
||||
from fastvideo.pipelines.basic.lingbot_video.stages import _preprocess_condition_image
|
||||
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_DENSE, OFFICIAL_DENSE, download_components
|
||||
|
||||
|
||||
def _load_native(checkpoint: Path, device: torch.device) -> FastVideoAutoencoderKLWan:
|
||||
"""Strict-load the released full VAE into FastVideo's native implementation."""
|
||||
vae_dir = checkpoint / "vae"
|
||||
raw_config = json.loads((vae_dir / "config.json").read_text())
|
||||
config = WanVAEConfig()
|
||||
config.update_model_arch({key: value for key, value in raw_config.items() if not key.startswith("_")})
|
||||
config.load_encoder = True
|
||||
config.load_decoder = True
|
||||
model = FastVideoAutoencoderKLWan(config)
|
||||
model.load_state_dict(load_file(vae_dir / "diffusion_pytorch_model.safetensors"), strict=True)
|
||||
return model.to(device=device, dtype=torch.float32).eval()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Wan VAE parity requires CUDA")
|
||||
def test_lingbot_video_ti2v_vae_encoder_exact_parity() -> None:
|
||||
"""Require equal posterior parameters and samples for one clean frame."""
|
||||
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
|
||||
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
|
||||
device = torch.device("cuda")
|
||||
local_official = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
|
||||
official_checkpoint = Path(local_official) if local_official else download_components(OFFICIAL_DENSE, "vae")
|
||||
local_converted = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
|
||||
converted_checkpoint = Path(local_converted) if local_converted else download_components(FASTVIDEO_DENSE, "vae")
|
||||
y, x = np.mgrid[0:941, 0:1672]
|
||||
image = Image.fromarray(
|
||||
np.stack((x % 256, y % 256, (x + y) % 256), axis=-1).astype(np.uint8),
|
||||
mode="RGB",
|
||||
)
|
||||
pixels = _preprocess_condition_image(image, 480, 832).to(device)
|
||||
normalized = (pixels - 0.5) / 0.5
|
||||
official = OfficialAutoencoderKLWan.from_pretrained(
|
||||
official_checkpoint / "vae",
|
||||
torch_dtype=torch.float32,
|
||||
local_files_only=True,
|
||||
).to(device).eval()
|
||||
native = _load_native(converted_checkpoint, device)
|
||||
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
|
||||
expected = official.encode(normalized).latent_dist
|
||||
actual = native.encode(normalized)
|
||||
expected_generator = torch.Generator(device=device).manual_seed(42)
|
||||
actual_generator = torch.Generator(device=device).manual_seed(42)
|
||||
expected_sample = expected.sample(expected_generator)
|
||||
actual_sample = actual.sample(actual_generator)
|
||||
for name, expected_tensor, actual_tensor in (
|
||||
("mean", expected.mean, actual.mean),
|
||||
("logvar", expected.logvar, actual.logvar),
|
||||
("sample", expected_sample, actual_sample),
|
||||
):
|
||||
difference = (actual_tensor.float() - expected_tensor.float()).abs()
|
||||
print(
|
||||
f"{name}: equal={torch.equal(actual_tensor, expected_tensor)} "
|
||||
f"differing={torch.count_nonzero(actual_tensor != expected_tensor).item()} "
|
||||
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
|
||||
)
|
||||
assert torch.equal(actual_tensor, expected_tensor)
|
||||
Reference in New Issue
Block a user