Skip to content

flux model/pipeline review #13584

Description

@hlky

flux model/pipeline review

Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423

Review performed against the repository review rules.

Duplicate search status: searched current GitHub Issues and PRs for flux, affected class/function names, and failure modes. No likely duplicates found. Broader gh searches hit rate limits after targeted searches, so remaining modular-pipeline searches were checked through the GitHub connector; related PRs such as #12272 and #13482 are not duplicates.

Issue 1: Flux IP-Adapter masks are accepted but ignored

Affected code:

def__call__(
self,
attn: "FluxAttention",
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor=None,
attention_mask: torch.Tensor|None=None,
image_rotary_emb: torch.Tensor|None=None,
ip_hidden_states: list[torch.Tensor] |None=None,
ip_adapter_masks: torch.Tensor|None=None,
) ->torch.Tensor:
batch_size=hidden_states.shape[0]
query, key, value, encoder_query, encoder_key, encoder_value=_get_qkv_projections(
attn, hidden_states, encoder_hidden_states
)
query=query.unflatten(-1, (attn.heads, -1))
key=key.unflatten(-1, (attn.heads, -1))
value=value.unflatten(-1, (attn.heads, -1))
query=attn.norm_q(query)
key=attn.norm_k(key)
ip_query=query
ifencoder_hidden_statesisnotNone:
encoder_query=encoder_query.unflatten(-1, (attn.heads, -1))
encoder_key=encoder_key.unflatten(-1, (attn.heads, -1))
encoder_value=encoder_value.unflatten(-1, (attn.heads, -1))
encoder_query=attn.norm_added_q(encoder_query)
encoder_key=attn.norm_added_k(encoder_key)
query=torch.cat([encoder_query, query], dim=1)
key=torch.cat([encoder_key, key], dim=1)
value=torch.cat([encoder_value, value], dim=1)
ifimage_rotary_embisnotNone:
query=apply_rotary_emb(query, image_rotary_emb, sequence_dim=1)
key=apply_rotary_emb(key, image_rotary_emb, sequence_dim=1)
hidden_states=dispatch_attention_fn(
query,
key,
value,
attn_mask=attention_mask,
dropout_p=0.0,
is_causal=False,
backend=self._attention_backend,
parallel_config=self._parallel_config,
)
hidden_states=hidden_states.flatten(2, 3)
hidden_states=hidden_states.to(query.dtype)
ifencoder_hidden_statesisnotNone:
encoder_hidden_states, hidden_states=hidden_states.split_with_sizes(
[encoder_hidden_states.shape[1], hidden_states.shape[1] -encoder_hidden_states.shape[1]], dim=1
)
hidden_states=attn.to_out[0](hidden_states)
hidden_states=attn.to_out[1](hidden_states)
encoder_hidden_states=attn.to_add_out(encoder_hidden_states)
# IP-adapter
ip_attn_output=torch.zeros_like(hidden_states)
forcurrent_ip_hidden_states, scale, to_k_ip, to_v_ipinzip(
ip_hidden_states, self.scale, self.to_k_ip, self.to_v_ip
):
ip_key=to_k_ip(current_ip_hidden_states)
ip_value=to_v_ip(current_ip_hidden_states)
ip_key=ip_key.view(batch_size, -1, attn.heads, attn.head_dim)
ip_value=ip_value.view(batch_size, -1, attn.heads, attn.head_dim)
current_ip_hidden_states=dispatch_attention_fn(
ip_query,
ip_key,
ip_value,
attn_mask=None,
dropout_p=0.0,
is_causal=False,
backend=self._attention_backend,
parallel_config=self._parallel_config,
)
current_ip_hidden_states=current_ip_hidden_states.reshape(batch_size, -1, attn.heads*attn.head_dim)
current_ip_hidden_states=current_ip_hidden_states.to(ip_query.dtype)
ip_attn_output+=scale*current_ip_hidden_states

quiet_attn_parameters= {"ip_adapter_masks", "ip_hidden_states"}
unused_kwargs= [kfork, _inkwargs.items() ifknotinattn_parametersandknotinquiet_attn_parameters]

Problem:
FluxIPAdapterAttnProcessor.__call__ accepts ip_adapter_masks, and FluxAttention.forward explicitly quiets that kwarg, but the processor never reads or applies the masks.

Impact:
Region-specific IP-Adapter conditioning silently has no effect for Flux. Users can pass masks without warnings, making masked IP-Adapter results misleading.

Reproduction:

importtorchfromdiffusers.models.transformers.transformer_fluximportFluxAttention, FluxIPAdapterAttnProcessortorch.manual_seed(0)
attn=FluxAttention(
query_dim=4,
heads=1,
dim_head=4,
added_kv_proj_dim=4,
processor=FluxIPAdapterAttnProcessor(hidden_size=4, cross_attention_dim=4, num_tokens=(2,), scale=10.0),
)
hidden=torch.randn(1, 3, 4)
encoder=torch.randn(1, 2, 4)
ip= [torch.randn(1, 2, 4)]
mask0= [torch.zeros(1, 3, 1, 1)]
mask1= [torch.ones(1, 3, 1, 1)]
out0=attn(hidden, encoder_hidden_states=encoder, ip_hidden_states=ip, ip_adapter_masks=mask0)[0]
out1=attn(hidden, encoder_hidden_states=encoder, ip_hidden_states=ip, ip_adapter_masks=mask1)[0]
print(torch.max(torch.abs(out0-out1)).item()) # 0.0

Relevant precedent:

ifip_adapter_masksisnotNone:
ifnotisinstance(ip_adapter_masks, list):
# for backward compatibility, we accept `ip_adapter_mask` as a tensor of shape [num_ip_adapter, 1, height, width]
ip_adapter_masks=list(ip_adapter_masks.unsqueeze(1))
ifnot (len(ip_adapter_masks) ==len(self.scale) ==len(ip_hidden_states)):
raiseValueError(
f"Length of ip_adapter_masks array ({len(ip_adapter_masks)}) must match "
f"length of self.scale array ({len(self.scale)}) and number of ip_hidden_states "
f"({len(ip_hidden_states)})"
)
else:
forindex, (mask, scale, ip_state) inenumerate(zip(ip_adapter_masks, self.scale, ip_hidden_states)):
ifmaskisNone:
continue
ifnotisinstance(mask, torch.Tensor) ormask.ndim!=4:
raiseValueError(
"Each element of the ip_adapter_masks array should be a tensor with shape "
"[1, num_images_for_ip_adapter, height, width]."
" Please use `IPAdapterMaskProcessor` to preprocess your mask"
)
ifmask.shape[1] !=ip_state.shape[1]:
raiseValueError(
f"Number of masks ({mask.shape[1]}) does not match "
f"number of ip images ({ip_state.shape[1]}) at index {index}"
)
ifisinstance(scale, list) andnotlen(scale) ==mask.shape[1]:
raiseValueError(
f"Number of masks ({mask.shape[1]}) does not match "
f"number of scales ({len(scale)}) at index {index}"
)
else:
ip_adapter_masks= [None] *len(self.scale)
# for ip-adapter
forcurrent_ip_hidden_states, scale, to_k_ip, to_v_ip, maskinzip(
ip_hidden_states, self.scale, self.to_k_ip, self.to_v_ip, ip_adapter_masks
):
skip=False
ifisinstance(scale, list):
ifall(s==0forsinscale):
skip=True
elifscale==0:
skip=True
ifnotskip:
ifmaskisnotNone:
ifnotisinstance(scale, list):
scale= [scale] *mask.shape[1]
current_num_images=mask.shape[1]
foriinrange(current_num_images):
ip_key=to_k_ip(current_ip_hidden_states[:, i, :, :])
ip_value=to_v_ip(current_ip_hidden_states[:, i, :, :])
ip_key=attn.head_to_batch_dim(ip_key)
ip_value=attn.head_to_batch_dim(ip_value)
ip_attention_probs=attn.get_attention_scores(query, ip_key, None)
_current_ip_hidden_states=torch.bmm(ip_attention_probs, ip_value)
_current_ip_hidden_states=attn.batch_to_head_dim(_current_ip_hidden_states)
mask_downsample=IPAdapterMaskProcessor.downsample(
mask[:, i, :, :],
batch_size,
_current_ip_hidden_states.shape[1],
_current_ip_hidden_states.shape[2],
)
mask_downsample=mask_downsample.to(dtype=query.dtype, device=query.device)
hidden_states=hidden_states+scale[i] * (_current_ip_hidden_states*mask_downsample)

Suggested fix:
Apply the same validation/downsampling pattern used by IPAdapterAttnProcessor2_0, adapted for Flux sequence-shaped hidden states. If mask support is not intended, remove ip_adapter_masks from the quieted kwargs so users get a warning.

Issue 2: negative_prompt_embeds do not enable true CFG in several Flux pipelines

Affected code:

do_true_cfg=true_cfg_scale>1andnegative_promptisnotNone

do_true_cfg=true_cfg_scale>1andnegative_promptisnotNone

do_true_cfg=true_cfg_scale>1andnegative_promptisnotNone

Problem:
These pipelines compute true CFG only with negative_prompt is not None. They validate and accept negative_prompt_embeds / negative_pooled_prompt_embeds, but embeddings alone do not activate true CFG.

Impact:
Advanced users passing precomputed negative embeddings get silently different behavior from text negative prompts.

Reproduction:

importtorchfromdiffusersimportFluxImg2ImgPipelinepipe=object.__new__(FluxImg2ImgPipeline)
pipe.vae_scale_factor=8pipe._callback_tensor_inputs= ["latents", "prompt_embeds"]
pipe.check_inputs(
prompt=None,
prompt_2=None,
strength=0.5,
height=64,
width=64,
prompt_embeds=torch.zeros(2, 4, 8),
pooled_prompt_embeds=torch.zeros(2, 8),
negative_prompt_embeds=torch.zeros(2, 4, 8),
negative_pooled_prompt_embeds=torch.zeros(2, 8),
callback_on_step_end_tensor_inputs=["latents"],
max_sequence_length=48,
)
negative_prompt=Nonetrue_cfg_scale=2.0print(true_cfg_scale>1andnegative_promptisnotNone) # False

Relevant precedent:

has_neg_prompt=negative_promptisnotNoneor (
negative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisnotNone
)
do_true_cfg=true_cfg_scale>1andhas_neg_prompt

has_neg_prompt=negative_promptisnotNoneor (
negative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisnotNone
)
do_true_cfg=true_cfg_scale>1andhas_neg_prompt

Suggested fix:

has_neg_prompt=negative_promptisnotNoneor (
negative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisnotNone
)
do_true_cfg=true_cfg_scale>1andhas_neg_prompt

Issue 3: Base Flux and Flux Kontext accept mismatched negative prompt embeddings

Affected code:

ifnegative_promptisnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt`: {negative_prompt} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
elifnegative_prompt_2isnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt_2`: {negative_prompt_2} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
ifprompt_embedsisnotNoneandpooled_prompt_embedsisNone:
raiseValueError(
"If `prompt_embeds` are provided, `pooled_prompt_embeds` also have to be passed. Make sure to generate `pooled_prompt_embeds` from the same text encoder that was used to generate `prompt_embeds`."
)
ifnegative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisNone:
raiseValueError(
"If `negative_prompt_embeds` are provided, `negative_pooled_prompt_embeds` also have to be passed. Make sure to generate `negative_pooled_prompt_embeds` from the same text encoder that was used to generate `negative_prompt_embeds`."

ifnegative_promptisnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt`: {negative_prompt} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
elifnegative_prompt_2isnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt_2`: {negative_prompt_2} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
ifprompt_embedsisnotNoneandpooled_prompt_embedsisNone:
raiseValueError(
"If `prompt_embeds` are provided, `pooled_prompt_embeds` also have to be passed. Make sure to generate `pooled_prompt_embeds` from the same text encoder that was used to generate `prompt_embeds`."
)
ifnegative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisNone:
raiseValueError(
"If `negative_prompt_embeds` are provided, `negative_pooled_prompt_embeds` also have to be passed. Make sure to generate `negative_pooled_prompt_embeds` from the same text encoder that was used to generate `negative_prompt_embeds`."

Problem:
FluxPipeline and FluxKontextPipeline check that negative pooled embeddings are provided with negative prompt embeddings, but they do not verify that negative and positive embedding shapes match.

Impact:
Shape or batch mismatches are accepted at input validation time and fail later, or worse, produce hard-to-debug true CFG behavior.

Reproduction:

importtorchfromdiffusersimportFluxPipeline, FluxKontextPipelineforclsin (FluxPipeline, FluxKontextPipeline):
pipe=object.__new__(cls)
pipe.vae_scale_factor=8pipe._callback_tensor_inputs= ["latents", "prompt_embeds"]
pipe.check_inputs(
prompt=None,
prompt_2=None,
height=64,
width=64,
prompt_embeds=torch.zeros(2, 4, 8),
pooled_prompt_embeds=torch.zeros(2, 8),
negative_prompt_embeds=torch.zeros(1, 4, 8),
negative_pooled_prompt_embeds=torch.zeros(1, 8),
callback_on_step_end_tensor_inputs=["latents"],
max_sequence_length=48,
)
print(cls.__name__, "accepted mismatched negative embeds")

Relevant precedent:

ifprompt_embedsisnotNoneandnegative_prompt_embedsisnotNone:
ifprompt_embeds.shape!=negative_prompt_embeds.shape:
raiseValueError(
"`prompt_embeds` and `negative_prompt_embeds` must have the same shape when passed directly, but"
f" got: `prompt_embeds` {prompt_embeds.shape} != `negative_prompt_embeds`"
f" {negative_prompt_embeds.shape}."
)

ifprompt_embedsisnotNoneandnegative_prompt_embedsisnotNone:
ifprompt_embeds.shape!=negative_prompt_embeds.shape:
raiseValueError(
"`prompt_embeds` and `negative_prompt_embeds` must have the same shape when passed directly, but"
f" got: `prompt_embeds` {prompt_embeds.shape} != `negative_prompt_embeds`"
f" {negative_prompt_embeds.shape}."
)

Suggested fix:

ifprompt_embedsisnotNoneandnegative_prompt_embedsisnotNone:
ifprompt_embeds.shape!=negative_prompt_embeds.shape:
raiseValueError(
"`negative_prompt_embeds` must have the same shape as `prompt_embeds`, "f"but got {negative_prompt_embeds.shape} != {prompt_embeds.shape}."
)

Issue 4: Flux ControlNet img2img validates dimensions with wrong operator precedence

Affected code:

defcheck_inputs(
self,
prompt,
prompt_2,
strength,
height,
width,
callback_on_step_end_tensor_inputs,
prompt_embeds=None,
pooled_prompt_embeds=None,
max_sequence_length=None,
):
ifstrength<0orstrength>1:
raiseValueError(f"The value of strength should in [0.0, 1.0] but is {strength}")
ifheight%self.vae_scale_factor*2!=0orwidth%self.vae_scale_factor*2!=0:
logger.warning(
f"`height` and `width` have to be divisible by {self.vae_scale_factor*2} but are {height} and {width}. Dimensions will be resized accordingly"

Problem:
The dimension check uses height % self.vae_scale_factor * 2, which is parsed as (height % self.vae_scale_factor) * 2, not height % (self.vae_scale_factor * 2).

Impact:
Invalid dimensions such as 72x72 pass validation when vae_scale_factor == 8, even though Flux latent packing expects dimensions divisible by 16.

Reproduction:

fromdiffusersimportFluxControlNetImg2ImgPipelinepipe=object.__new__(FluxControlNetImg2ImgPipeline)
pipe.vae_scale_factor=8pipe._callback_tensor_inputs= ["latents", "prompt_embeds", "control_image"]
pipe.check_inputs(
prompt="x",
prompt_2=None,
strength=0.5,
height=72,
width=72,
callback_on_step_end_tensor_inputs=["latents"],
prompt_embeds=None,
pooled_prompt_embeds=None,
max_sequence_length=48,
)
print(72%pipe.vae_scale_factor*2) # 0, current checkprint(72% (pipe.vae_scale_factor*2)) # 8, intended check

Relevant precedent:

defcheck_inputs(
self,
prompt,
prompt_2,
strength,
height,
width,
negative_prompt=None,
negative_prompt_2=None,
prompt_embeds=None,
negative_prompt_embeds=None,
pooled_prompt_embeds=None,
negative_pooled_prompt_embeds=None,
callback_on_step_end_tensor_inputs=None,
max_sequence_length=None,
):
ifstrength<0orstrength>1:
raiseValueError(f"The value of strength should in [0.0, 1.0] but is {strength}")
ifheight% (self.vae_scale_factor*2) !=0orwidth% (self.vae_scale_factor*2) !=0:
logger.warning(
f"`height` and `width` have to be divisible by {self.vae_scale_factor*2} but are {height} and {width}. Dimensions will be resized accordingly"

defcheck_inputs(
self,
prompt,
prompt_2,
height,
width,
negative_prompt=None,
negative_prompt_2=None,
prompt_embeds=None,
negative_prompt_embeds=None,
pooled_prompt_embeds=None,
negative_pooled_prompt_embeds=None,
callback_on_step_end_tensor_inputs=None,
max_sequence_length=None,
):
ifheight% (self.vae_scale_factor*2) !=0orwidth% (self.vae_scale_factor*2) !=0:
logger.warning(

Suggested fix:

ifheight% (self.vae_scale_factor*2) !=0orwidth% (self.vae_scale_factor*2) !=0:
raiseValueError(...)

Issue 5: Flux Prior Redux misses tensor-batch and pooled-scale validation

Affected code:

defcheck_inputs(
self,
image,
prompt,
prompt_2,
prompt_embeds=None,
pooled_prompt_embeds=None,
prompt_embeds_scale=1.0,
pooled_prompt_embeds_scale=1.0,
):
ifpromptisnotNoneandprompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `prompt`: {prompt} and `prompt_embeds`: {prompt_embeds}. Please make sure to"
" only forward one of the two."
)
elifprompt_2isnotNoneandprompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `prompt_2`: {prompt_2} and `prompt_embeds`: {prompt_embeds}. Please make sure to"
" only forward one of the two."
)
elifpromptisnotNoneand (notisinstance(prompt, str) andnotisinstance(prompt, list)):
raiseValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
elifprompt_2isnotNoneand (notisinstance(prompt_2, str) andnotisinstance(prompt_2, list)):
raiseValueError(f"`prompt_2` has to be of type `str` or `list` but is {type(prompt_2)}")
ifpromptisnotNoneand (isinstance(prompt, list) andisinstance(image, list) andlen(prompt) !=len(image)):
raiseValueError(
f"number of prompts must be equal to number of images, but {len(prompt)} prompts were provided and {len(image)} images"
)
ifprompt_embedsisnotNoneandpooled_prompt_embedsisNone:
raiseValueError(
"If `prompt_embeds` are provided, `pooled_prompt_embeds` also have to be passed. Make sure to generate `pooled_prompt_embeds` from the same text encoder that was used to generate `prompt_embeds`."
)
ifisinstance(prompt_embeds_scale, list) and (
isinstance(image, list) andlen(prompt_embeds_scale) !=len(image)
):
raiseValueError(
f"number of weights must be equal to number of images, but {len(prompt_embeds_scale)} weights were provided and {len(image)} images"

batch_size=1
elifimageisnotNoneandisinstance(image, list):
batch_size=len(image)
else:
batch_size=image.shape[0]
ifpromptisnotNoneandisinstance(prompt, str):
prompt=batch_size* [prompt]
ifisinstance(prompt_embeds_scale, float):
prompt_embeds_scale=batch_size* [prompt_embeds_scale]
ifisinstance(pooled_prompt_embeds_scale, float):
pooled_prompt_embeds_scale=batch_size* [pooled_prompt_embeds_scale]

prompt_embeds*=torch.tensor(prompt_embeds_scale, device=device, dtype=image_embeds.dtype)[:, None, None]
pooled_prompt_embeds*=torch.tensor(pooled_prompt_embeds_scale, device=device, dtype=image_embeds.dtype)[
:, None
]

Problem:
check_inputs only checks image batch size when image is a list. Tensor image batches are skipped. It also checks prompt_embeds_scale list length but not pooled_prompt_embeds_scale.

Impact:
Mismatched image/prompt batches and scale lengths are accepted, then fail later during tensor arithmetic or produce confusing broadcasting behavior.

Reproduction:

importtorchfromdiffusersimportFluxPriorReduxPipelinepipe=object.__new__(FluxPriorReduxPipeline)
pipe.check_inputs(
image=torch.zeros(2, 3, 32, 32),
prompt=["first", "second", "third"],
prompt_2=None,
prompt_embeds=None,
pooled_prompt_embeds=None,
prompt_embeds_scale=[1.0],
pooled_prompt_embeds_scale=[1.0, 1.0, 1.0],
)
print("accepted tensor image batch=2 with prompt batch=3 and prompt scale length=1")

Relevant precedent:
Flux text/image pipelines consistently validate paired prompt embedding inputs before denoising, for example:

ifnegative_promptisnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt`: {negative_prompt} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
elifnegative_prompt_2isnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt_2`: {negative_prompt_2} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
ifprompt_embedsisnotNoneandpooled_prompt_embedsisNone:
raiseValueError(
"If `prompt_embeds` are provided, `pooled_prompt_embeds` also have to be passed. Make sure to generate `pooled_prompt_embeds` from the same text encoder that was used to generate `prompt_embeds`."
)
ifnegative_prompt_embedsisnotNoneandnegative_pooled_prompt_embedsisNone:
raiseValueError(
"If `negative_prompt_embeds` are provided, `negative_pooled_prompt_embeds` also have to be passed. Make sure to generate `negative_pooled_prompt_embeds` from the same text encoder that was used to generate `negative_prompt_embeds`."

Suggested fix:
Determine image batch size for tensors as well as lists, then validate prompt batch size and both scale arguments against that batch size.

image_batch_size=image.shape[0] ifisinstance(image, torch.Tensor) elselen(image) ifisinstance(image, list) else1forname, scalein {
"prompt_embeds_scale": prompt_embeds_scale,
"pooled_prompt_embeds_scale": pooled_prompt_embeds_scale,
}.items():
ifisinstance(scale, list) andlen(scale) !=image_batch_size:
raiseValueError(f"`{name}` must have length {image_batch_size}, but got {len(scale)}.")

Issue 6: Flux modular pipelines import standard Flux pipelines and QwenImage modular helpers

Affected code:


latents=FluxPipeline._pack_latents(latents, batch_size, num_channels_latents, height, width)

block_state.img_ids=FluxPipeline._prepare_latent_image_ids(None, height//2, width//2, device, dtype)
self.set_block_state(state, block_state)
returncomponents, state
classFluxKontextRoPEInputsStep(ModularPipelineBlocks):
model_name="flux-kontext"
@property
defdescription(self) ->str:
return"Step that prepares the RoPE inputs for the denoising process of Flux Kontext. Should be placed after text encoder and latent preparation steps."
@property
definputs(self) ->list[InputParam]:
return [
InputParam(name="image_height"),
InputParam(name="image_width"),
InputParam(name="height"),
InputParam(name="width"),
InputParam(name="prompt_embeds"),
]
@property
defintermediate_outputs(self) ->list[OutputParam]:
return [
OutputParam(
name="txt_ids",
kwargs_type="denoiser_input_fields",
type_hint=list[int],
description="The sequence lengths of the prompt embeds, used for RoPE calculation.",
),
OutputParam(
name="img_ids",
kwargs_type="denoiser_input_fields",
type_hint=list[int],
description="The sequence lengths of the image latents, used for RoPE calculation.",
),
]
def__call__(self, components: FluxModularPipeline, state: PipelineState) ->PipelineState:
block_state=self.get_block_state(state)
prompt_embeds=block_state.prompt_embeds
device, dtype=prompt_embeds.device, prompt_embeds.dtype
block_state.txt_ids=torch.zeros(prompt_embeds.shape[1], 3).to(
device=prompt_embeds.device, dtype=prompt_embeds.dtype
)
img_ids=None
if (
getattr(block_state, "image_height", None) isnotNone
andgetattr(block_state, "image_width", None) isnotNone
):
image_latent_height=2* (int(block_state.image_height) // (components.vae_scale_factor*2))
image_latent_width=2* (int(block_state.image_width) // (components.vae_scale_factor*2))
img_ids=FluxPipeline._prepare_latent_image_ids(
None, image_latent_height//2, image_latent_width//2, device, dtype
)
# image ids are the same as latent ids with the first dimension set to 1 instead of 0
img_ids[..., 0] =1
height=2* (int(block_state.height) // (components.vae_scale_factor*2))
width=2* (int(block_state.width) // (components.vae_scale_factor*2))
latent_ids=FluxPipeline._prepare_latent_image_ids(None, height//2, width//2, device, dtype)

from ...pipelinesimportFluxPipeline
from ...utilsimportlogging
from ..modular_pipelineimportModularPipelineBlocks, PipelineState
from ..modular_pipeline_utilsimportInputParam, OutputParam
# TODO: consider making these common utilities for modular if they are not pipeline-specific.
from ..qwenimage.inputsimportcalculate_dimension_from_latents, repeat_tensor_to_batch_size

image_latent_tensor=FluxPipeline._pack_latents(
image_latent_tensor, block_state.batch_size, image_latent_tensor.shape[1], latent_height, latent_width
)
# 3. Expand batch size
image_latent_tensor=repeat_tensor_to_batch_size(
input_name=image_latent_input_name,
input_tensor=image_latent_tensor,
num_images_per_prompt=block_state.num_images_per_prompt,
batch_size=block_state.batch_size,
)
setattr(block_state, image_latent_input_name, image_latent_tensor)
# Process additional batch inputs (only batch expansion)
forinput_nameinself._additional_batch_inputs:
input_tensor=getattr(block_state, input_name)
ifinput_tensorisNone:
continue
# Only expand batch size
input_tensor=repeat_tensor_to_batch_size(
input_name=input_name,
input_tensor=input_tensor,
num_images_per_prompt=block_state.num_images_per_prompt,
batch_size=block_state.batch_size,
)
setattr(block_state, input_name, input_tensor)
self.set_block_state(state, block_state)
returncomponents, state
classFluxKontextAdditionalInputsStep(FluxAdditionalInputsStep):
model_name="flux-kontext"
def__call__(self, components: FluxModularPipeline, state: PipelineState) ->PipelineState:
block_state=self.get_block_state(state)
# Process image latent inputs (height/width calculation, patchify, and batch expansion)
forimage_latent_input_nameinself._image_latent_inputs:
image_latent_tensor=getattr(block_state, image_latent_input_name)
ifimage_latent_tensorisNone:
continue
# 1. Calculate height/width from latents
# Unlike the `FluxAdditionalInputsStep`, we don't overwrite the `block.height` and `block.width`
height, width=calculate_dimension_from_latents(image_latent_tensor, components.vae_scale_factor)
ifnothasattr(block_state, "image_height"):
block_state.image_height=height
ifnothasattr(block_state, "image_width"):
block_state.image_width=width
# 2. Patchify the image latent tensor
# TODO: Implement patchifier for Flux.
latent_height, latent_width=image_latent_tensor.shape[2:]
image_latent_tensor=FluxPipeline._pack_latents(
image_latent_tensor, block_state.batch_size, image_latent_tensor.shape[1], latent_height, latent_width
)
# 3. Expand batch size
image_latent_tensor=repeat_tensor_to_batch_size(

@torch.no_grad()
def__call__(self, components: FluxModularPipeline, state: PipelineState):
from ...pipelines.flux.pipeline_flux_kontextimportPREFERRED_KONTEXT_RESOLUTIONS

Problem:
The modular review rules prohibit hidden imports from classic pipelines and cross-family modular imports. Flux modular blocks import FluxPipeline for private helper methods and import QwenImage modular input helpers.

Impact:
This creates hidden coupling between modular Flux, classic Flux, and QwenImage. Refactors or lazy-loading changes in one family can break another, and modular Flux becomes harder to serialize, test, and maintain independently.

Reproduction:

frompathlibimportPathforpathinPath("src/diffusers/modular_pipelines/flux").glob("*.py"):
forline_no, lineinenumerate(path.read_text().splitlines(), 1):
if"from ...pipelines"inlineor"from ..qwenimage"inlineor"FluxPipeline._"inline:
print(f"{path}:{line_no}: {line.strip()}")

Relevant precedent:
decoders.py keeps its Flux latent unpacking helper local instead of importing the classic pipeline:

def_unpack_latents(latents, height, width, vae_scale_factor):
batch_size, num_patches, channels=latents.shape
# VAE applies 8x compression on images but we must also account for packing which requires
# latent height and width to be divisible by 2.
height=2* (int(height) // (vae_scale_factor*2))
width=2* (int(width) // (vae_scale_factor*2))
latents=latents.view(batch_size, height//2, width//2, channels//4, 2, 2)
latents=latents.permute(0, 3, 1, 4, 2, 5)
latents=latents.reshape(batch_size, channels// (2*2), height, width)

Suggested fix:
Move shared latent packing, latent id, and dimension helpers into Flux modular-local helpers or a neutral utility module that both classic and modular Flux can import without creating pipeline-family coupling. Copy the Kontext resolution table into Flux modular code or move it to a neutral Flux constants module.

Issue 7: Slow coverage is missing for most Flux pipeline variants

Affected code:

classFluxImg2ImgPipelineFastTests(unittest.TestCase, PipelineTesterMixin, FluxIPAdapterTesterMixin):

classFluxInpaintPipelineFastTests(unittest.TestCase, PipelineTesterMixin, FluxIPAdapterTesterMixin):

classFluxFillPipelineFastTests(unittest.TestCase, PipelineTesterMixin):

classFluxControlPipelineFastTests(unittest.TestCase, PipelineTesterMixin):

classFluxControlImg2ImgPipelineFastTests(unittest.TestCase, PipelineTesterMixin):

classFluxControlInpaintPipelineFastTests(unittest.TestCase, PipelineTesterMixin):

classFluxKontextPipelineFastTests(

classFluxKontextInpaintPipelineFastTests(

classFluxControlNetImg2ImgPipelineFastTests(unittest.TestCase, PipelineTesterMixin):

classFluxControlNetInpaintPipelineTests(unittest.TestCase, PipelineTesterMixin):

classTestFluxModularPipelineFast(ModularPipelineTesterMixin):
pipeline_class=FluxModularPipeline
pipeline_blocks_class=FluxAutoBlocks
pretrained_model_name_or_path="hf-internal-testing/tiny-flux-modular"
params=frozenset(["prompt", "height", "width", "guidance_scale"])
batch_params=frozenset(["prompt"])
expected_workflow_blocks=FLUX_TEXT2IMAGE_WORKFLOWS
defget_dummy_inputs(self, seed=0):
generator=self.get_generator(seed)
inputs= {
"prompt": "A painting of a squirrel eating a burger",
"generator": generator,
"num_inference_steps": 2,
"guidance_scale": 5.0,
"height": 8,
"width": 8,
"max_sequence_length": 48,
"output_type": "pt",
}
returninputs
deftest_float16_inference(self):
super().test_float16_inference(9e-2)
FLUX_IMAGE2IMAGE_WORKFLOWS= {
"image2image": [
("text_encoder", "FluxTextEncoderStep"),
("vae_encoder.preprocess", "FluxProcessImagesInputStep"),
("vae_encoder.encode", "FluxVaeEncoderStep"),
("denoise.input.text_inputs", "FluxTextInputStep"),
("denoise.input.additional_inputs", "FluxAdditionalInputsStep"),
("denoise.before_denoise.prepare_latents", "FluxPrepareLatentsStep"),
("denoise.before_denoise.set_timesteps", "FluxImg2ImgSetTimestepsStep"),
("denoise.before_denoise.prepare_img2img_latents", "FluxImg2ImgPrepareLatentsStep"),
("denoise.before_denoise.prepare_rope_inputs", "FluxRoPEInputsStep"),
("denoise.denoise", "FluxDenoiseStep"),
("decode", "FluxDecodeStep"),
]
}
classTestFluxImg2ImgModularPipelineFast(ModularPipelineTesterMixin):
pipeline_class=FluxModularPipeline
pipeline_blocks_class=FluxAutoBlocks
pretrained_model_name_or_path="hf-internal-testing/tiny-flux-modular"
params=frozenset(["prompt", "height", "width", "guidance_scale", "image"])
batch_params=frozenset(["prompt", "image"])
expected_workflow_blocks=FLUX_IMAGE2IMAGE_WORKFLOWS
defget_pipeline(self, components_manager=None, torch_dtype=torch.float32):
pipeline=super().get_pipeline(components_manager, torch_dtype)
# Override `vae_scale_factor` here as currently, `image_processor` is initialized with
# fixed constants instead of
# https://github.com/huggingface/diffusers/blob/d54622c2679d700b425ad61abce9b80fc36212c0/src/diffusers/pipelines/flux/pipeline_flux_img2img.py#L230C9-L232C10
pipeline.image_processor=VaeImageProcessor(vae_scale_factor=2)
returnpipeline
defget_dummy_inputs(self, seed=0):
generator=self.get_generator(seed)
inputs= {
"prompt": "A painting of a squirrel eating a burger",
"generator": generator,
"num_inference_steps": 4,
"guidance_scale": 5.0,
"height": 8,
"width": 8,
"max_sequence_length": 48,
"output_type": "pt",
}
image=floats_tensor((1, 3, 32, 32), rng=random.Random(seed)).to(torch_device)
image=image.cpu().permute(0, 2, 3, 1)[0]
init_image=PIL.Image.fromarray(np.uint8(image)).convert("RGB")
inputs["image"] =init_image
inputs["strength"] =0.5
returninputs
deftest_save_from_pretrained(self, tmp_path):
pipes= []
base_pipe=self.get_pipeline().to(torch_device)
pipes.append(base_pipe)
base_pipe.save_pretrained(str(tmp_path))
pipe=ModularPipeline.from_pretrained(tmp_path).to(torch_device)
pipe.load_components(torch_dtype=torch.float32)
pipe.to(torch_device)
pipe.image_processor=VaeImageProcessor(vae_scale_factor=2)
pipes.append(pipe)
image_slices= []
forpipeinpipes:
inputs=self.get_dummy_inputs()
image=pipe(**inputs, output="images")
image_slices.append(image[0, -3:, -3:, -1].flatten())
asserttorch.abs(image_slices[0] -image_slices[1]).max() <1e-3
deftest_float16_inference(self):
super().test_float16_inference(8e-2)
FLUX_KONTEXT_WORKFLOWS= {
"text2image": [
("text_encoder", "FluxTextEncoderStep"),
("denoise.input", "FluxTextInputStep"),
("denoise.before_denoise.prepare_latents", "FluxPrepareLatentsStep"),
("denoise.before_denoise.set_timesteps", "FluxSetTimestepsStep"),
("denoise.before_denoise.prepare_rope_inputs", "FluxRoPEInputsStep"),
("denoise.denoise", "FluxKontextDenoiseStep"),
("decode", "FluxDecodeStep"),
],
"image_conditioned": [
("text_encoder", "FluxTextEncoderStep"),
("vae_encoder.preprocess", "FluxKontextProcessImagesInputStep"),
("vae_encoder.encode", "FluxVaeEncoderStep"),
("denoise.input.set_resolution", "FluxKontextSetResolutionStep"),
("denoise.input.text_inputs", "FluxTextInputStep"),
("denoise.input.additional_inputs", "FluxKontextAdditionalInputsStep"),
("denoise.before_denoise.prepare_latents", "FluxPrepareLatentsStep"),
("denoise.before_denoise.set_timesteps", "FluxSetTimestepsStep"),
("denoise.before_denoise.prepare_rope_inputs", "FluxKontextRoPEInputsStep"),
("denoise.denoise", "FluxKontextDenoiseStep"),
("decode", "FluxDecodeStep"),
],
}
classTestFluxKontextModularPipelineFast(ModularPipelineTesterMixin):

Problem:
Fast tests exist for these variants, but slow/nightly coverage is missing for Flux img2img, inpaint, fill, control, control img2img, control inpaint, Kontext, Kontext inpaint, ControlNet img2img, ControlNet inpaint, and Flux modular pipelines.

Impact:
Several public Flux variants are not covered against real checkpoints, schedulers, tokenizer/text-encoder stacks, or pipeline loading paths. This is especially risky for Flux because many bugs only appear with real component shapes, offload behavior, attention processors, and true CFG paths.

Reproduction:

importastfrompathlibimportPathfiles=sorted(Path("tests").glob("**/*flux*.py"))
forpathinfiles:
tree=ast.parse(path.read_text())
slow_classes= []
fornodeintree.body:
ifisinstance(node, ast.ClassDef):
decorators= {getattr(d, "id", getattr(d, "attr", "")) fordinnode.decorator_list}
ifdecorators& {"slow", "nightly"}:
slow_classes.append(node.name)
ifpath.match("tests/pipelines/flux/*") orpath.match("tests/pipelines/controlnet_flux/*") orpath.match("tests/modular_pipelines/flux/*"):
print(path, slow_classes)

Relevant precedent:
Existing real-checkpoint Flux slow coverage is present for base Flux, Flux Redux, and base Flux ControlNet:

@nightly
@require_big_accelerator
classFluxPipelineSlowTests(unittest.TestCase):
pipeline_class=FluxPipeline
repo_id="black-forest-labs/FLUX.1-schnell"
defsetUp(self):
super().setUp()
gc.collect()
backend_empty_cache(torch_device)
deftearDown(self):
super().tearDown()
gc.collect()
backend_empty_cache(torch_device)
defget_inputs(self, device, seed=0):
generator=torch.Generator(device="cpu").manual_seed(seed)
prompt_embeds=torch.load(
hf_hub_download(repo_id="diffusers/test-slices", repo_type="dataset", filename="flux/prompt_embeds.pt")
).to(torch_device)
pooled_prompt_embeds=torch.load(
hf_hub_download(
repo_id="diffusers/test-slices", repo_type="dataset", filename="flux/pooled_prompt_embeds.pt"
)
).to(torch_device)
return {
"prompt_embeds": prompt_embeds,
"pooled_prompt_embeds": pooled_prompt_embeds,
"num_inference_steps": 2,
"guidance_scale": 0.0,
"max_sequence_length": 256,
"output_type": "np",
"generator": generator,
}
deftest_flux_inference(self):
pipe=self.pipeline_class.from_pretrained(
self.repo_id, torch_dtype=torch.bfloat16, text_encoder=None, text_encoder_2=None
).to(torch_device)
inputs=self.get_inputs(torch_device)
image=pipe(**inputs).images[0]
image_slice=image[0, :10, :10]
# fmt: off
expected_slices=Expectations(
{
("cuda", None): np.array([0.3242, 0.3203, 0.3164, 0.3164, 0.3125, 0.3125, 0.3281, 0.3242, 0.3203, 0.3301, 0.3262, 0.3242, 0.3281, 0.3242, 0.3203, 0.3262, 0.3262, 0.3164, 0.3262, 0.3281, 0.3184, 0.3281, 0.3281, 0.3203, 0.3281, 0.3281, 0.3164, 0.3320, 0.3320, 0.3203], dtype=np.float32,),
("xpu", 3): np.array([0.3301, 0.3281, 0.3359, 0.3203, 0.3203, 0.3281, 0.3281, 0.3301, 0.3340, 0.3281, 0.3320, 0.3359, 0.3281, 0.3301, 0.3320, 0.3242, 0.3301, 0.3281, 0.3242, 0.3320, 0.3320, 0.3281, 0.3320, 0.3320, 0.3262, 0.3320, 0.3301, 0.3301, 0.3359, 0.3320], dtype=np.float32,),
}
)
expected_slice=expected_slices.get_expectation()
# fmt: on
max_diff=numpy_cosine_similarity_distance(expected_slice.flatten(), image_slice.flatten())
self.assertLess(
max_diff, 1e-4, f"Image slice is different from expected slice: {image_slice} != {expected_slice}"
)
@slow
@require_big_accelerator
classFluxIPAdapterPipelineSlowTests(unittest.TestCase):

@slow
@require_big_accelerator
classFluxReduxSlowTests(unittest.TestCase):

@nightly
@require_big_accelerator
classFluxControlNetPipelineSlowTests(unittest.TestCase):

Suggested fix:
Add at least one @slow or @nightly smoke test per missing public variant using a small deterministic inference path and real public checkpoint components where available. For modular Flux, add slow parity coverage against the corresponding classic Flux pipeline for text-to-image, image-to-image, and Kontext paths.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions