1919import pytest
2020import torch
2121
22+ import fastvideo .models .vaes .hunyuan15vae as hunyuan15vae
23+ from fastvideo .configs .pipelines .hunyuan15 import (Hunyuan15I2V480PStepDistilledConfig , Hunyuan15I2V720PConfig ,
24+ Hunyuan15SR1080PConfig , Hunyuan15T2V480PConfig ,
25+ Hunyuan15T2V720PConfig )
26+ from fastvideo .models .vaes .hunyuan15vae import AutoencoderKLHunyuanVideo15
2227from fastvideo .pipelines .stages .image_encoding import Hy15ImageEncodingStage
2328
24- LATENT_SHAPE = (1 , 32 , 5 , 8 , 12 ) # B, C, T, H, W
25- REFERENCE_IMAGE = PIL .Image .new ("RGB" , (LATENT_SHAPE [4 ] * 8 , LATENT_SHAPE [3 ] * 8 ), (120 , 60 , 30 ))
29+ VAE_SPATIAL_COMPRESSION_RATIO = 16
30+ LATENT_SHAPE = (1 , 32 , 5 , 4 , 6 ) # B, C, T, H, W
31+ REFERENCE_IMAGE = PIL .Image .new (
32+ "RGB" ,
33+ (LATENT_SHAPE [4 ] * VAE_SPATIAL_COMPRESSION_RATIO , LATENT_SHAPE [3 ] * VAE_SPATIAL_COMPRESSION_RATIO ),
34+ (120 , 60 , 30 ),
35+ )
2636VISION_TOKENS = 729
2737VISION_DIM = 1152
2838
@@ -33,8 +43,10 @@ class _FakeSiglip(torch.nn.Module):
3343 def __init__ (self ) -> None :
3444 super ().__init__ ()
3545 self .marker = torch .nn .Parameter (torch .ones (1 ))
46+ self .forward_calls = 0
3647
3748 def forward (self , pixel_values : torch .Tensor ):
49+ self .forward_calls += 1
3850 batch = pixel_values .shape [0 ]
3951 return SimpleNamespace (last_hidden_state = torch .full ((batch , VISION_TOKENS , VISION_DIM ), 0.5 ))
4052
@@ -45,37 +57,62 @@ def preprocess(self, images, **kwargs):
4557 return SimpleNamespace (to = lambda ** _ : {"pixel_values" : torch .zeros (1 , 3 , 384 , 384 )})
4658
4759
48- class _FakeVAE (torch .nn .Module ):
49- """Encodes to a constant so the first frame is distinguishable from zero ."""
60+ class _ConstantEncoder (torch .nn .Module ):
61+ """Small encoder used inside the real HunyuanVideo 1.5 VAE wrapper ."""
5062
51- def __init__ (self ) -> None :
63+ def __init__ (self , out_channels : int , spatial_compression_ratio : int , ** kwargs ) -> None :
64+ super ().__init__ ()
65+ self .out_channels = out_channels
66+ self .spatial_compression_ratio = spatial_compression_ratio
67+ self .forward_calls = 0
68+
69+ def forward (self , pixels : torch .Tensor ) -> torch .Tensor :
70+ self .forward_calls += 1
71+ batch_size , _ , _ , height , width = pixels .shape
72+ return torch .full (
73+ (
74+ batch_size ,
75+ self .out_channels ,
76+ 1 ,
77+ height // self .spatial_compression_ratio ,
78+ width // self .spatial_compression_ratio ,
79+ ),
80+ 3.0 ,
81+ device = pixels .device ,
82+ dtype = pixels .dtype ,
83+ )
84+
85+
86+ class _UnusedDecoder (torch .nn .Module ):
87+
88+ def __init__ (self , ** kwargs ) -> None :
5289 super ().__init__ ()
53- self .config = SimpleNamespace (scaling_factor = 2.0 )
54-
55- def encode (self , pixels : torch .Tensor ):
56- _ , _ , _ , height , width = pixels .shape
57- latent = torch .full ((1 , LATENT_SHAPE [1 ], 1 , height // 8 , width // 8 ), 3.0 )
58- return SimpleNamespace (mode = lambda : latent )
5990
60- def to (self , * args , ** kwargs ) :
61- return self
91+ def forward (self , latents : torch . Tensor ) -> torch . Tensor :
92+ return latents
6293
6394
64- def _batch (pil_image ):
95+ def _batch (pil_image , batch_size : int = 1 ):
96+ raw_latent_shape = (batch_size , ) + LATENT_SHAPE [1 :]
6597 return SimpleNamespace (
6698 pil_image = pil_image ,
67- raw_latent_shape = LATENT_SHAPE ,
99+ raw_latent_shape = raw_latent_shape ,
68100 image_embeds = [],
69101 image_latent = None ,
70102 video_latent = None ,
71- height = LATENT_SHAPE [3 ] * 8 ,
72- width = LATENT_SHAPE [4 ] * 8 ,
103+ height = LATENT_SHAPE [3 ] * VAE_SPATIAL_COMPRESSION_RATIO ,
104+ width = LATENT_SHAPE [4 ] * VAE_SPATIAL_COMPRESSION_RATIO ,
73105 )
74106
75107
76108def _args ():
77109 return SimpleNamespace (
78- pipeline_config = SimpleNamespace (vae_precision = "fp32" ),
110+ pipeline_config = SimpleNamespace (
111+ vae_precision = "fp32" ,
112+ vae_tiling = True ,
113+ vae_config = SimpleNamespace (
114+ arch_config = SimpleNamespace (spatial_compression_ratio = VAE_SPATIAL_COMPRESSION_RATIO )),
115+ ),
79116 disable_autocast = True ,
80117 image_encoder_cpu_offload = False ,
81118 vae_cpu_offload = False ,
@@ -95,14 +132,63 @@ def _cpu_device(monkeypatch):
95132 )
96133
97134
98- def _i2v_stage ():
135+ @pytest .fixture
136+ def lightweight_vae_factory (monkeypatch ):
137+ monkeypatch .setattr (hunyuan15vae , "HunyuanVideo15Encoder3D" , _ConstantEncoder )
138+ monkeypatch .setattr (hunyuan15vae , "HunyuanVideo15Decoder3D" , _UnusedDecoder )
139+
140+ def build (pipeline_config = None ):
141+ if pipeline_config is None :
142+ pipeline_config = Hunyuan15I2V480PStepDistilledConfig ()
143+ return AutoencoderKLHunyuanVideo15 (pipeline_config .vae_config )
144+
145+ return build
146+
147+
148+ @pytest .fixture
149+ def i2v_stage (lightweight_vae_factory ):
99150 return Hy15ImageEncodingStage (
100151 image_encoder = _FakeSiglip (),
101152 image_processor = _FakeProcessor (),
102- vae = _FakeVAE (),
153+ vae = lightweight_vae_factory (),
103154 )
104155
105156
157+ @pytest .mark .parametrize (
158+ "config_cls" ,
159+ [Hunyuan15I2V480PStepDistilledConfig , Hunyuan15I2V720PConfig ],
160+ )
161+ def test_i2v_configs_build_the_vae_encoder_and_decoder (config_cls , lightweight_vae_factory ) -> None :
162+ pipeline_config = config_cls ()
163+ vae = lightweight_vae_factory (pipeline_config )
164+
165+ assert pipeline_config .vae_config .load_encoder is True
166+ assert pipeline_config .vae_config .load_decoder is True
167+ assert pipeline_config .text_encoder_configs [0 ].arch_config .output_hidden_states is True
168+ assert isinstance (vae .encoder , _ConstantEncoder )
169+ assert isinstance (vae .decoder , _UnusedDecoder )
170+
171+
172+ @pytest .mark .parametrize (
173+ "config_cls" ,
174+ [Hunyuan15I2V480PStepDistilledConfig , Hunyuan15I2V720PConfig ],
175+ )
176+ def test_i2v_configs_reject_disabling_the_vae_encoder (config_cls ) -> None :
177+ pipeline_config = config_cls ()
178+ pipeline_config .update_config_from_dict ({"vae_config.load_encoder" : False })
179+
180+ with pytest .raises (ValueError , match = "requires the VAE encoder" ):
181+ pipeline_config .check_pipeline_config ()
182+
183+
184+ @pytest .mark .parametrize (
185+ "config_cls" ,
186+ [Hunyuan15T2V480PConfig , Hunyuan15T2V720PConfig , Hunyuan15SR1080PConfig ],
187+ )
188+ def test_non_i2v_configs_keep_the_vae_encoder_disabled (config_cls ) -> None :
189+ assert config_cls ().vae_config .load_encoder is False
190+
191+
106192class TestTextToVideoPath :
107193 """No reference image: the transformer must still see the T2V layout."""
108194
@@ -124,18 +210,25 @@ def test_conditioning_stays_on_the_video_latent_slot(self) -> None:
124210 assert torch .all (batch .video_latent == 0 )
125211 assert batch .image_latent is None
126212
213+ def test_placeholders_follow_the_latent_batch (self ) -> None :
214+ batch_size = 2
215+ batch = Hy15ImageEncodingStage ().forward (_batch (None , batch_size = batch_size ), _args ())
216+
217+ assert batch .image_embeds [0 ].shape == (batch_size , VISION_TOKENS , VISION_DIM )
218+ assert batch .video_latent .shape == (batch_size , 1 ) + LATENT_SHAPE [2 :]
219+
127220
128221class TestImageToVideoPath :
129222
130- def test_image_embeds_come_from_siglip (self ) -> None :
131- batch = _i2v_stage () .forward (_batch (REFERENCE_IMAGE ), _args ())
223+ def test_image_embeds_come_from_siglip (self , i2v_stage ) -> None :
224+ batch = i2v_stage .forward (_batch (REFERENCE_IMAGE ), _args ())
132225
133226 assert batch .image_embeds [0 ].shape == (1 , VISION_TOKENS , VISION_DIM )
134227 # Non-zero is the whole point: all-zero would re-select the T2V branch.
135228 assert not torch .all (batch .image_embeds [0 ] == 0 )
136229
137- def test_channel_layout_is_conditioning_then_mask (self ) -> None :
138- batch = _i2v_stage () .forward (_batch (REFERENCE_IMAGE ), _args ())
230+ def test_channel_layout_is_conditioning_then_mask (self , i2v_stage ) -> None :
231+ batch = i2v_stage .forward (_batch (REFERENCE_IMAGE ), _args ())
139232
140233 assert batch .image_latent .shape == (1 , LATENT_SHAPE [1 ] + 1 ) + LATENT_SHAPE [2 :]
141234
@@ -149,20 +242,46 @@ def test_channel_layout_is_conditioning_then_mask(self) -> None:
149242 assert torch .all (conditioning [:, :, 0 ] != 0 )
150243 assert torch .all (conditioning [:, :, 1 :] == 0 )
151244
152- def test_scaling_factor_is_applied (self ) -> None :
153- batch = _i2v_stage () .forward (_batch (REFERENCE_IMAGE ), _args ())
245+ def test_scaling_factor_is_applied (self , i2v_stage ) -> None :
246+ batch = i2v_stage .forward (_batch (REFERENCE_IMAGE ), _args ())
154247
155- # Fake VAE emits 3.0 and declares scaling_factor 2.0.
248+ expected = 3.0 * i2v_stage . vae . config . scaling_factor
156249 assert torch .allclose (batch .image_latent [:, :LATENT_SHAPE [1 ], 0 ],
157- torch .full ((1 , LATENT_SHAPE [1 ]) + LATENT_SHAPE [3 :], 6.0 ))
250+ torch .full ((1 , LATENT_SHAPE [1 ]) + LATENT_SHAPE [3 :], expected ))
158251
159- def test_video_latent_left_unset (self ) -> None :
160- batch = _i2v_stage () .forward (_batch (REFERENCE_IMAGE ), _args ())
252+ def test_video_latent_left_unset (self , i2v_stage ) -> None :
253+ batch = i2v_stage .forward (_batch (REFERENCE_IMAGE ), _args ())
161254
162255 # DenoisingStage checks video_latent first and would append the mask
163256 # ahead of the conditioning, so this slot has to stay empty.
164257 assert batch .video_latent is None
165258
259+ def test_single_reference_conditioning_repeats_to_the_latent_batch (self , i2v_stage ) -> None :
260+ batch_size = 2
261+ batch = i2v_stage .forward (_batch (REFERENCE_IMAGE , batch_size = batch_size ), _args ())
262+
263+ expected_shape = (batch_size , LATENT_SHAPE [1 ] + 1 ) + LATENT_SHAPE [2 :]
264+ assert batch .image_latent .shape == expected_shape
265+ assert batch .image_embeds [0 ].shape == (batch_size , VISION_TOKENS , VISION_DIM )
266+ assert torch .equal (batch .image_latent [0 ], batch .image_latent [1 ])
267+ assert torch .equal (batch .image_embeds [0 ][0 ], batch .image_embeds [0 ][1 ])
268+
269+ conditioning = batch .image_latent [:, :LATENT_SHAPE [1 ]]
270+ mask = batch .image_latent [:, LATENT_SHAPE [1 ]:]
271+ assert torch .all (conditioning [:, :, 0 ] != 0 )
272+ assert torch .all (conditioning [:, :, 1 :] == 0 )
273+ assert torch .all (mask [:, :, 0 ] == 1 )
274+ assert torch .all (mask [:, :, 1 :] == 0 )
275+
276+ assert i2v_stage .vae .encoder .forward_calls == 1
277+ assert i2v_stage .image_encoder .forward_calls == 1
278+
279+ noise_latents = torch .zeros ((batch_size , ) + LATENT_SHAPE [1 :])
280+ assert torch .cat ([noise_latents , batch .image_latent ], dim = 1 ).shape == (
281+ batch_size ,
282+ LATENT_SHAPE [1 ] * 2 + 1 ,
283+ ) + LATENT_SHAPE [2 :]
284+
166285 def test_missing_encoder_is_an_explicit_error (self ) -> None :
167286 with pytest .raises (ValueError , match = "image encoder" ):
168287 Hy15ImageEncodingStage ().forward (_batch (REFERENCE_IMAGE ), _args ())
0 commit comments