Skip to content

Commit 05402cb

Browse files
[bugfix]: condition HunyuanVideo 1.5 i2v on the reference image
Fixes #1692. Passing image_path to HunyuanVideo15ImageToVideoPipeline raised StageVerificationError before denoising. Hy15ImageEncodingStage only wrote image_embeds on the branch where pil_image is None, so with an image the field kept its ForwardBatch default of [] and the inherited output check rejected it. Nothing encoded the image either way: the SiglipVisionModel and SiglipImageProcessor the i2v checkpoints ship were not in the pipeline's required modules, and the stage was constructed with image_encoder=None. Declare the SigLIP pair on the two i2v pipeline configs, load it in the i2v pipeline, and give the stage the path it lacked: SigLIP hidden states into image_embeds, VAE latents of the reference image into the first frame of the conditioning stream, and a mask channel set to 1 on that frame. Channel order is the part that is easy to get wrong, because it is invisible until an image is supplied. DenoisingStage appends video_latent and a zero pad separately, which yields [latents 32][mask 1][conditioning 32], but the model expects [latents 32][conditioning 32][mask 1]. For t2v both trailing blocks are zero, so the two orders produce the same tensor and the discrepancy never surfaces. The i2v path therefore populates image_latent as one 33 channel block and leaves video_latent unset, so denoising takes the branch that appends it whole. Reference for the expected layout is the diffusers HunyuanVideo15ImageToVideoPipeline, which builds cat([latents, cond_latents_concat, mask_concat], dim=1); HYWorldImageEncodingStage already assembles image_latent the same way. t2v and the two super-resolution pipelines are untouched. They never carry a pil_image, so they keep the existing branch, including video_latent, which SRDenoisingStage reads directly. Adds CPU tests over both paths: the t2v zeros, the SigLIP embeddings being non-zero so the transformer's is_t2v check turns off, the conditioning and mask landing in that order with the mask on frame 0 only, the scaling factor being applied, video_latent staying unset, and a clear error when a pipeline without the encoder is handed an image. fastvideo/tests/stages/test_hy15_image_encoding_stage.py: 7 passed Not verified here: an end to end i2v run on a GPU. Doing that next and will post the before and after in the issue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 15568f2 commit 05402cb

4 files changed

Lines changed: 260 additions & 7 deletions

File tree

fastvideo/configs/pipelines/hunyuan15.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
1010
from fastvideo.configs.models.dits import HunyuanVideo15Config
11-
from fastvideo.configs.models.encoders import (BaseEncoderOutput, Qwen2_5_VLConfig, T5Config)
11+
from fastvideo.configs.models.encoders import (BaseEncoderOutput, Qwen2_5_VLConfig, SiglipVisionConfig, T5Config)
1212
from fastvideo.configs.models.vaes import Hunyuan15VAEConfig
1313
from fastvideo.configs.models.upsamplers import SRTo720pUpsamplerConfig, SRTo1080pUpsamplerConfig
1414
from fastvideo.configs.pipelines.base import PipelineConfig, UpsamplerConfig
@@ -124,6 +124,10 @@ def __post_init__(self):
124124
class Hunyuan15I2V480PStepDistilledConfig(Hunyuan15T2V480PConfig):
125125
flow_shift: int = 7
126126

127+
# The i2v checkpoints ship a SigLIP vision tower; declaring it here is what
128+
# makes the loader build one.
129+
image_encoder_config: EncoderConfig = field(default_factory=SiglipVisionConfig)
130+
127131

128132
@dataclass
129133
class Hunyuan15T2V720PConfig(Hunyuan15T2V480PConfig):
@@ -140,6 +144,8 @@ class Hunyuan15I2V720PConfig(Hunyuan15T2V720PConfig):
140144
# HunyuanConfig-specific parameters with defaults
141145
flow_shift: int = 7
142146

147+
image_encoder_config: EncoderConfig = field(default_factory=SiglipVisionConfig)
148+
143149

144150
@dataclass
145151
class Hunyuan15SR1080PConfig(Hunyuan15T2V720PConfig):

fastvideo/pipelines/basic/hunyuan15/hunyuan15_i2v_pipeline.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@
2020

2121
class HunyuanVideo15ImageToVideoPipeline(ComposedPipelineBase):
2222

23+
# image_encoder and feature_extractor are the SigLIP pair the i2v
24+
# checkpoints ship; without them the reference image has no route into the
25+
# transformer and the run is text-to-video wearing an i2v label.
2326
_required_config_modules = [
24-
"text_encoder", "text_encoder_2", "tokenizer", "tokenizer_2", "vae", "transformer", "scheduler"
27+
"text_encoder", "text_encoder_2", "tokenizer", "tokenizer_2", "vae", "transformer", "scheduler",
28+
"image_encoder", "feature_extractor"
2529
]
2630

2731
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs):
@@ -47,7 +51,9 @@ def create_pipeline_stages(self, fastvideo_args: FastVideoArgs):
4751
transformer=self.get_module("transformer")))
4852

4953
self.add_stage(stage_name="image_encoding_stage",
50-
stage=Hy15ImageEncodingStage(image_encoder=None, image_processor=None))
54+
stage=Hy15ImageEncodingStage(image_encoder=self.get_module("image_encoder"),
55+
image_processor=self.get_module("feature_extractor"),
56+
vae=self.get_module("vae")))
5157

5258
self.add_stage(stage_name="denoising_stage",
5359
stage=DenoisingStage(transformer=self.get_module("transformer"),

fastvideo/pipelines/stages/image_encoding.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from fastvideo.distributed import get_local_torch_device
1616
from fastvideo.fastvideo_args import ExecutionMode, FastVideoArgs
1717
from fastvideo.forward_context import set_forward_context
18+
from fastvideo.image_processor import ImageProcessor
1819
from fastvideo.logger import init_logger
1920
from fastvideo.models.vaes.common import ParallelTiledVAE
2021
from fastvideo.models.vision_utils import (get_default_height_width, normalize, numpy_to_pt, pil_to_numpy, resize,
@@ -97,22 +98,94 @@ def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> V
9798
class Hy15ImageEncodingStage(ImageEncodingStage):
9899
"""
99100
Stage for encoding image prompts into embeddings for HunyuanVideo1.5 models.
101+
102+
Without a reference image (T2V and the super-resolution pipelines) the
103+
conditioning slots are zero-filled and the transformer takes its T2V branch.
104+
With one, SigLIP supplies ``image_embeds`` and the VAE supplies the first
105+
frame of the conditioning latent, which is what turns that branch off.
100106
"""
101107

108+
def __init__(self, image_encoder=None, image_processor=None, vae=None):
109+
super().__init__(image_encoder=image_encoder, image_processor=image_processor)
110+
self.vae = vae
111+
102112
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
103113
"""Verify image encoding stage inputs."""
104114
return VerificationResult()
105115

116+
@torch.no_grad()
106117
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
107118
"""
108119
Encode the prompt into image encoder hidden states.
109120
"""
121+
device = get_local_torch_device()
122+
110123
if batch.pil_image is None:
111-
batch.image_embeds = [torch.zeros(1, 729, 1152, device=get_local_torch_device())]
124+
batch.image_embeds = [torch.zeros(1, 729, 1152, device=device)]
125+
raw_latent_shape = list(batch.raw_latent_shape)
126+
raw_latent_shape[1] = 1
127+
batch.video_latent = torch.zeros(tuple(raw_latent_shape), device=device)
128+
return batch
129+
130+
if self.image_encoder is None or self.image_processor is None or self.vae is None:
131+
raise ValueError("HunyuanVideo 1.5 needs an image encoder, its processor and the VAE to condition on a "
132+
"reference image, but this pipeline was built without them. Image-to-video requires "
133+
"HunyuanVideo15ImageToVideoPipeline and an i2v checkpoint.")
134+
135+
_, latent_channels, latent_temporal, latent_height, latent_width = batch.raw_latent_shape
136+
137+
# 1. SigLIP hidden states. The transformer reads all-zero image
138+
# embeddings as "this is T2V", so this is what selects the I2V branch.
139+
self.image_encoder = self.image_encoder.to(device)
140+
encoder_dtype = next(self.image_encoder.parameters()).dtype
141+
image_inputs = self.image_processor.preprocess(
142+
images=batch.pil_image,
143+
do_resize=True,
144+
do_convert_rgb=True,
145+
return_tensors="pt",
146+
).to(device=device, dtype=encoder_dtype)
147+
with set_forward_context(current_timestep=0, attn_metadata=None):
148+
batch.image_embeds = [self.image_encoder(**image_inputs).last_hidden_state]
149+
if fastvideo_args.image_encoder_cpu_offload:
150+
self.image_encoder.to("cpu")
112151

113-
raw_latent_shape = list(batch.raw_latent_shape)
114-
raw_latent_shape[1] = 1
115-
batch.video_latent = torch.zeros(tuple(raw_latent_shape), device=get_local_torch_device())
152+
# 2. The reference image occupies the first latent frame of the
153+
# conditioning stream; every later frame is zero and the mask channel
154+
# marks which one is real.
155+
vae_dtype = PRECISION_TO_TYPE[fastvideo_args.pipeline_config.vae_precision]
156+
vae_autocast_enabled = (vae_dtype != torch.float32) and not fastvideo_args.disable_autocast
157+
158+
image_processor = ImageProcessor(vae_scale_factor=8)
159+
pixels = image_processor.preprocess(batch.pil_image, batch.height, batch.width)
160+
pixels = pixels.unsqueeze(2).to(device=device, dtype=vae_dtype)
161+
162+
self.vae = self.vae.to(device)
163+
with torch.autocast(device_type="cuda", dtype=vae_dtype, enabled=vae_autocast_enabled):
164+
cond_latents = self.vae.encode(pixels).mode()
165+
cond_latents = cond_latents * self.vae.config.scaling_factor
166+
if fastvideo_args.vae_cpu_offload:
167+
self.vae.to("cpu")
168+
169+
cond_latents = cond_latents.repeat(1, 1, latent_temporal, 1, 1)
170+
cond_latents[:, :, 1:] = 0.0
171+
172+
mask = torch.zeros(
173+
1,
174+
1,
175+
latent_temporal,
176+
latent_height,
177+
latent_width,
178+
device=device,
179+
dtype=cond_latents.dtype,
180+
)
181+
mask[:, :, 0] = 1.0
182+
183+
# Latents, then conditioning, then mask: the denoising stage appends
184+
# ``image_latent`` as one block, so the channel order has to be built
185+
# here. Leaving ``video_latent`` unset keeps it out of the earlier
186+
# branch, which would append the mask before the conditioning.
187+
batch.image_latent = torch.cat([cond_latents, mask], dim=1)
188+
assert batch.image_latent.shape[1] == latent_channels + 1
116189
return batch
117190

118191

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""CPU tests for ``Hy15ImageEncodingStage``.
3+
4+
The failure this guards against is quiet: with no reference image conditioning,
5+
HunyuanVideo 1.5 still trains and still samples, it just ignores the image. So
6+
these assert the channel layout rather than only that a tensor came back.
7+
8+
Ordering matters and is invisible in T2V. ``DenoisingStage`` appends
9+
``image_latent`` as one block, so the 65 channel input is
10+
``[latents 32][conditioning 32][mask 1]``. For T2V the last 33 are all zero and
11+
either order produces the same tensor, which is why a swapped layout would go
12+
unnoticed until an image is actually supplied.
13+
"""
14+
from __future__ import annotations
15+
16+
from types import SimpleNamespace
17+
18+
import PIL.Image
19+
import pytest
20+
import torch
21+
22+
from fastvideo.pipelines.stages.image_encoding import Hy15ImageEncodingStage
23+
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))
26+
VISION_TOKENS = 729
27+
VISION_DIM = 1152
28+
29+
30+
class _FakeSiglip(torch.nn.Module):
31+
"""Returns a constant non-zero hidden state, which is all the stage reads."""
32+
33+
def __init__(self) -> None:
34+
super().__init__()
35+
self.marker = torch.nn.Parameter(torch.ones(1))
36+
37+
def forward(self, pixel_values: torch.Tensor):
38+
batch = pixel_values.shape[0]
39+
return SimpleNamespace(last_hidden_state=torch.full((batch, VISION_TOKENS, VISION_DIM), 0.5))
40+
41+
42+
class _FakeProcessor:
43+
44+
def preprocess(self, images, **kwargs):
45+
return SimpleNamespace(to=lambda **_: {"pixel_values": torch.zeros(1, 3, 384, 384)})
46+
47+
48+
class _FakeVAE(torch.nn.Module):
49+
"""Encodes to a constant so the first frame is distinguishable from zero."""
50+
51+
def __init__(self) -> None:
52+
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)
59+
60+
def to(self, *args, **kwargs):
61+
return self
62+
63+
64+
def _batch(pil_image):
65+
return SimpleNamespace(
66+
pil_image=pil_image,
67+
raw_latent_shape=LATENT_SHAPE,
68+
image_embeds=[],
69+
image_latent=None,
70+
video_latent=None,
71+
height=LATENT_SHAPE[3] * 8,
72+
width=LATENT_SHAPE[4] * 8,
73+
)
74+
75+
76+
def _args():
77+
return SimpleNamespace(
78+
pipeline_config=SimpleNamespace(vae_precision="fp32"),
79+
disable_autocast=True,
80+
image_encoder_cpu_offload=False,
81+
vae_cpu_offload=False,
82+
)
83+
84+
85+
@pytest.fixture(autouse=True)
86+
def _cpu_device(monkeypatch):
87+
"""Pin the stage to CPU so the fakes and the real code agree on device.
88+
89+
``get_local_torch_device`` resolves to MPS on macOS and CUDA elsewhere, and
90+
nothing here needs an accelerator.
91+
"""
92+
monkeypatch.setattr(
93+
"fastvideo.pipelines.stages.image_encoding.get_local_torch_device",
94+
lambda: torch.device("cpu"),
95+
)
96+
97+
98+
def _i2v_stage():
99+
return Hy15ImageEncodingStage(
100+
image_encoder=_FakeSiglip(),
101+
image_processor=_FakeProcessor(),
102+
vae=_FakeVAE(),
103+
)
104+
105+
106+
class TestTextToVideoPath:
107+
"""No reference image: the transformer must still see the T2V layout."""
108+
109+
def test_image_embeds_are_zero(self) -> None:
110+
batch = Hy15ImageEncodingStage().forward(_batch(None), _args())
111+
112+
assert len(batch.image_embeds) == 1
113+
assert batch.image_embeds[0].shape == (1, VISION_TOKENS, VISION_DIM)
114+
# The transformer keys its T2V branch off this being all zero.
115+
assert torch.all(batch.image_embeds[0] == 0)
116+
117+
def test_conditioning_stays_on_the_video_latent_slot(self) -> None:
118+
batch = Hy15ImageEncodingStage().forward(_batch(None), _args())
119+
120+
# The super-resolution stages read video_latent directly, so T2V keeps
121+
# populating it rather than switching to image_latent.
122+
assert batch.video_latent is not None
123+
assert batch.video_latent.shape == (1, 1) + LATENT_SHAPE[2:]
124+
assert torch.all(batch.video_latent == 0)
125+
assert batch.image_latent is None
126+
127+
128+
class TestImageToVideoPath:
129+
130+
def test_image_embeds_come_from_siglip(self) -> None:
131+
batch = _i2v_stage().forward(_batch(REFERENCE_IMAGE), _args())
132+
133+
assert batch.image_embeds[0].shape == (1, VISION_TOKENS, VISION_DIM)
134+
# Non-zero is the whole point: all-zero would re-select the T2V branch.
135+
assert not torch.all(batch.image_embeds[0] == 0)
136+
137+
def test_channel_layout_is_conditioning_then_mask(self) -> None:
138+
batch = _i2v_stage().forward(_batch(REFERENCE_IMAGE), _args())
139+
140+
assert batch.image_latent.shape == (1, LATENT_SHAPE[1] + 1) + LATENT_SHAPE[2:]
141+
142+
conditioning = batch.image_latent[:, :LATENT_SHAPE[1]]
143+
mask = batch.image_latent[:, LATENT_SHAPE[1]:]
144+
145+
# A swapped layout would put a 1-wide block first, so check that the
146+
# trailing channel is the mask and not part of the conditioning.
147+
assert torch.all(mask[:, :, 0] == 1.0)
148+
assert torch.all(mask[:, :, 1:] == 0.0)
149+
assert torch.all(conditioning[:, :, 0] != 0)
150+
assert torch.all(conditioning[:, :, 1:] == 0)
151+
152+
def test_scaling_factor_is_applied(self) -> None:
153+
batch = _i2v_stage().forward(_batch(REFERENCE_IMAGE), _args())
154+
155+
# Fake VAE emits 3.0 and declares scaling_factor 2.0.
156+
assert torch.allclose(batch.image_latent[:, :LATENT_SHAPE[1], 0],
157+
torch.full((1, LATENT_SHAPE[1]) + LATENT_SHAPE[3:], 6.0))
158+
159+
def test_video_latent_left_unset(self) -> None:
160+
batch = _i2v_stage().forward(_batch(REFERENCE_IMAGE), _args())
161+
162+
# DenoisingStage checks video_latent first and would append the mask
163+
# ahead of the conditioning, so this slot has to stay empty.
164+
assert batch.video_latent is None
165+
166+
def test_missing_encoder_is_an_explicit_error(self) -> None:
167+
with pytest.raises(ValueError, match="image encoder"):
168+
Hy15ImageEncodingStage().forward(_batch(REFERENCE_IMAGE), _args())

0 commit comments

Comments
 (0)