Skip to content

Commit 3ecaeba

Browse files
[bugfix]: load the VAE encoder for HunyuanVideo 1.5 i2v and honor the latent batch
Both I2V configs inherited Hunyuan15T2V480PConfig.__post_init__, which sets vae_config.load_encoder = False. The i2v image-encoding stage calls self.vae.encode, and AutoencoderKLHunyuanVideo15 builds its encoder only when load_encoder is set, so every real checkpoint raised AttributeError before denoising. The shipped test hid this behind a fake VAE. Both I2V configs now enable the encoder after super().__post_init__ and check_pipeline_config rejects a config that turns it back off. The stage also assumed a batch of one. SigLIP embeddings, the VAE conditioning latents, and the mask now expand to the latent batch, and an incompatible batch raises instead of broadcasting wrong. The generic denoising stage still asserts batch size one, so this fixes the stage contract, not end-to-end batched generation. The encode path gained two guards it could not reach before this fix made it live. Tiling now follows pipeline_config.vae_tiling like every other VAE encoding stage in the file, since the decoding stage enables tiling on the shared VAE and nothing turns it off, which made the first and later generations encode the same reference differently. The reference image is aligned to the VAE's spatial compression ratio of 16 rather than a hardcoded 8, which let a height like 552 crash inside the encoder reshape. The test builds the real AutoencoderKLHunyuanVideo15 from the real I2V config with a lightweight encoder and decoder, so the load_encoder wiring is what is under test. Reverting the production half fails ten test ids, five of them with the original AttributeError.
1 parent ea0ad12 commit 3ecaeba

3 files changed

Lines changed: 209 additions & 37 deletions

File tree

fastvideo/configs/pipelines/hunyuan15.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class Hunyuan15T2V480PConfig(PipelineConfig):
112112

113113
vae_tiling: bool = True
114114

115-
def __post_init__(self):
115+
def __post_init__(self) -> None:
116116
self.vae_config.load_encoder = False
117117
self.vae_config.load_decoder = True
118118
if self.text_encoder_configs:
@@ -128,6 +128,15 @@ class Hunyuan15I2V480PStepDistilledConfig(Hunyuan15T2V480PConfig):
128128
# makes the loader build one.
129129
image_encoder_config: EncoderConfig = field(default_factory=SiglipVisionConfig)
130130

131+
def __post_init__(self) -> None:
132+
super().__post_init__()
133+
self.vae_config.load_encoder = True
134+
135+
def check_pipeline_config(self) -> None:
136+
super().check_pipeline_config()
137+
if not self.vae_config.load_encoder:
138+
raise ValueError("HunyuanVideo 1.5 I2V requires the VAE encoder.")
139+
131140

132141
@dataclass
133142
class Hunyuan15T2V720PConfig(Hunyuan15T2V480PConfig):
@@ -146,6 +155,15 @@ class Hunyuan15I2V720PConfig(Hunyuan15T2V720PConfig):
146155

147156
image_encoder_config: EncoderConfig = field(default_factory=SiglipVisionConfig)
148157

158+
def __post_init__(self) -> None:
159+
super().__post_init__()
160+
self.vae_config.load_encoder = True
161+
162+
def check_pipeline_config(self) -> None:
163+
super().check_pipeline_config()
164+
if not self.vae_config.load_encoder:
165+
raise ValueError("HunyuanVideo 1.5 I2V requires the VAE encoder.")
166+
149167

150168
@dataclass
151169
class Hunyuan15SR1080PConfig(Hunyuan15T2V720PConfig):

fastvideo/pipelines/stages/image_encoding.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,10 @@ def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> Forward
119119
Encode the prompt into image encoder hidden states.
120120
"""
121121
device = get_local_torch_device()
122+
batch_size = batch.raw_latent_shape[0]
122123

123124
if batch.pil_image is None:
124-
batch.image_embeds = [torch.zeros(1, 729, 1152, device=device)]
125+
batch.image_embeds = [torch.zeros(batch_size, 729, 1152, device=device)]
125126
raw_latent_shape = list(batch.raw_latent_shape)
126127
raw_latent_shape[1] = 1
127128
batch.video_latent = torch.zeros(tuple(raw_latent_shape), device=device)
@@ -145,7 +146,15 @@ def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> Forward
145146
return_tensors="pt",
146147
).to(device=device, dtype=encoder_dtype)
147148
with set_forward_context(current_timestep=0, attn_metadata=None):
148-
batch.image_embeds = [self.image_encoder(**image_inputs).last_hidden_state]
149+
image_embeds = self.image_encoder(**image_inputs).last_hidden_state
150+
# The public input is one reference image. Encode it once, then reuse
151+
# the conditioning for every latent requested from that image.
152+
if image_embeds.shape[0] == 1:
153+
image_embeds = image_embeds.repeat(batch_size, 1, 1)
154+
elif image_embeds.shape[0] != batch_size:
155+
raise ValueError(f"HunyuanVideo 1.5 image embeddings have batch size {image_embeds.shape[0]}, "
156+
f"but the latent batch size is {batch_size}.")
157+
batch.image_embeds = [image_embeds]
149158
if fastvideo_args.image_encoder_cpu_offload:
150159
self.image_encoder.to("cpu")
151160

@@ -155,22 +164,48 @@ def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> Forward
155164
vae_dtype = PRECISION_TO_TYPE[fastvideo_args.pipeline_config.vae_precision]
156165
vae_autocast_enabled = (vae_dtype != torch.float32) and not fastvideo_args.disable_autocast
157166

158-
image_processor = ImageProcessor(vae_scale_factor=8)
167+
# The HunyuanVideo 1.5 VAE compresses space by 16 and latent
168+
# preparation divides by the same ratio, so aligning the reference
169+
# image to 8 lets a height like 552 reach the encoder and fail
170+
# inside its reshape.
171+
image_processor = ImageProcessor(
172+
vae_scale_factor=fastvideo_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio)
159173
pixels = image_processor.preprocess(batch.pil_image, batch.height, batch.width)
160174
pixels = pixels.unsqueeze(2).to(device=device, dtype=vae_dtype)
161175

162176
self.vae = self.vae.to(device)
163177
with torch.autocast(device_type="cuda", dtype=vae_dtype, enabled=vae_autocast_enabled):
178+
# The decoding stage turns tiling on and nothing turns it back off,
179+
# and both stages hold the same VAE. Without this the first
180+
# generation in a process encodes the reference image untiled and
181+
# every later one encodes it tiled, so the same image and seed give
182+
# different conditioning. Decide from the config, as the other
183+
# VAE-encoding stages in this file do.
184+
if fastvideo_args.pipeline_config.vae_tiling:
185+
self.vae.enable_tiling()
164186
cond_latents = self.vae.encode(pixels).mode()
165187
cond_latents = cond_latents * self.vae.config.scaling_factor
166188
if fastvideo_args.vae_cpu_offload:
167189
self.vae.to("cpu")
168190

169-
cond_latents = cond_latents.repeat(1, 1, latent_temporal, 1, 1)
170-
cond_latents[:, :, 1:] = 0.0
191+
if cond_latents.shape[0] == 1:
192+
cond_latents = cond_latents.repeat(batch_size, 1, 1, 1, 1)
193+
elif cond_latents.shape[0] != batch_size:
194+
raise ValueError(f"HunyuanVideo 1.5 conditioning latents have batch size {cond_latents.shape[0]}, "
195+
f"but the requested latent batch size is {batch_size}.")
196+
197+
first_frame_latents = cond_latents[:, :, :1]
198+
cond_latents = cond_latents.new_zeros(
199+
batch_size,
200+
latent_channels,
201+
latent_temporal,
202+
latent_height,
203+
latent_width,
204+
)
205+
cond_latents[:, :, :1] = first_frame_latents
171206

172207
mask = torch.zeros(
173-
1,
208+
batch_size,
174209
1,
175210
latent_temporal,
176211
latent_height,

fastvideo/tests/stages/test_hy15_image_encoding_stage.py

Lines changed: 149 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@
1919
import pytest
2020
import 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
2227
from 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+
)
2636
VISION_TOKENS = 729
2737
VISION_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

76108
def _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+
106192
class 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

128221
class 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

Comments
 (0)