Spaces:
Running on Zero
Running on Zero
Download diffusers/modular_pipelines/ltx2/encoders.py from diffusers-modular/flux3-image-klein-bbox: direct link, hf CLI and curl.
- Browser
- Download file 58.7 kB
-
https://huggingface.co/spaces/diffusers-modular/flux3-image-klein-bbox/resolve/main/diffusers/modular_pipelines/ltx2/encoders.py
- Command line
-
hf download hf://spaces/diffusers-modular/flux3-image-klein-bbox/diffusers/modular_pipelines/ltx2/encoders.py
-
curl -L -o encoders.py https://huggingface.co/spaces/diffusers-modular/flux3-image-klein-bbox/resolve/main/diffusers/modular_pipelines/ltx2/encoders.py
58.7 kB
| # Copyright 2026 The HuggingFace Team. All rights reserved. | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| import math | |
| from typing import Any | |
| import numpy as np | |
| import PIL.Image | |
| import torch | |
| from transformers import PreTrainedModel, PreTrainedTokenizerBase, ProcessorMixin | |
| from ...configuration_utils import FrozenDict | |
| from ...models import AutoencoderKLLTX2Video, LTX2VideoTransformer3DModel | |
| # NOTE (modular.md gotcha #1): `LTX2TextConnectors`, `LTX2DurationHead`, `LTX2VideoCondition`, | |
| # `LTX2ReferenceCondition`, the prompt-enhancement config/helpers, and the system prompts live under | |
| # `diffusers.pipelines.ltx2.*`, and modular blocks must not import from `diffusers.pipelines.*`. | |
| # `LTX2TextConnectors` / `LTX2DurationHead` are `ModelMixin` / `ConfigMixin` model classes (relocate to | |
| # `src/diffusers/models/`); the two condition dataclasses, the enhancement config, the response/image helpers, and | |
| # the system prompts are plain data and utilities (relocate to a neutral shared module or copy into this package). | |
| # Imported from the pipelines path here only so the draft is runnable. | |
| from ...pipelines.ltx2.connectors import LTX2TextConnectors | |
| from ...pipelines.ltx2.duration_head import LTX2DurationHead | |
| from ...pipelines.ltx2.pipeline_ltx2_condition import LTX2VideoCondition | |
| from ...pipelines.ltx2.pipeline_ltx2_ic_lora import LTX2ReferenceCondition | |
| from ...pipelines.ltx2.prompt_enhancement import ( | |
| _pad_inputs_for_attention_alignment, | |
| _prepare_enhance_image, | |
| clean_response, | |
| ) | |
| from ...pipelines.ltx2.utils import ( | |
| GEMMA4_PROMPT_ENHANCEMENT_CONFIG, | |
| LTX2_5_I2V_DEFAULT_SYSTEM_PROMPT, | |
| LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT, | |
| apply_image_conditioning_crf, | |
| resolve_default_image_crf, | |
| ) | |
| from ...utils import logging | |
| from ...video_processor import VideoProcessor | |
| from ..modular_pipeline import ModularPipelineBlocks, PipelineState | |
| from ..modular_pipeline_utils import ComponentSpec, InputParam, OutputParam | |
| logger = logging.get_logger(__name__) | |
| def _enhance_prompt( | |
| components, | |
| prompt: str | list[str], | |
| system_prompt: str, | |
| image: Any | None, | |
| max_new_tokens: int | None, | |
| seed: int, | |
| generator: torch.Generator | None, | |
| generation_kwargs: dict[str, Any] | None, | |
| device: torch.device, | |
| ) -> list[str]: | |
| # Mirrors `LTX2Pipeline.enhance_prompt` for the LTX-2.5 path only: a dedicated `prompt_enhancer` | |
| # (Gemma-4) with the greedy `GEMMA4_PROMPT_ENHANCEMENT_CONFIG` recipe. The LTX-2.0/2.3 | |
| # `text_encoder`-as-enhancer fallback is intentionally dropped (LTX-2.5-only integration). | |
| config = GEMMA4_PROMPT_ENHANCEMENT_CONFIG | |
| generation_kwargs = dict(generation_kwargs) if generation_kwargs is not None else dict(config.generation_kwargs) | |
| if max_new_tokens is None: | |
| max_new_tokens = config.max_new_tokens | |
| # Templates match ltx-core `LTXGemmaTextEncoder.enhance_t2v` / `enhance_i2v`. | |
| if image is None: | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": f"user prompt: {prompt}"}, | |
| ] | |
| enhance_image = None | |
| else: | |
| enhance_image = _prepare_enhance_image(image) | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image"}, | |
| {"type": "text", "text": f"User Raw Input Prompt: {prompt}."}, | |
| ], | |
| }, | |
| ] | |
| template = components.processor.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| model_inputs = components.processor(text=template, images=enhance_image, return_tensors="pt").to(device) | |
| pad_token_id = ( | |
| components.processor.tokenizer.pad_token_id if components.processor.tokenizer.pad_token_id is not None else 0 | |
| ) | |
| model_inputs = _pad_inputs_for_attention_alignment(model_inputs, pad_token_id=pad_token_id) | |
| components.prompt_enhancer.to(device) | |
| # `transformers.GenerationMixin.generate` does not support a `torch.Generator`, so seed manually for | |
| # reproducibility. (Inert for LTX-2.5's greedy decoding, but honored if the user passes sampling kwargs.) | |
| if generator is not None: | |
| seed = generator.initial_seed() if not isinstance(generator, list) else generator[0].initial_seed() | |
| torch.manual_seed(seed) | |
| generated_sequences = components.prompt_enhancer.generate( | |
| **model_inputs, max_new_tokens=max_new_tokens, **generation_kwargs | |
| ) | |
| generated_ids = [seq[len(model_inputs.input_ids[i]) :] for i, seq in enumerate(generated_sequences)] | |
| enhanced_prompt = components.processor.tokenizer.batch_decode(generated_ids, skip_special_tokens=True) | |
| return [clean_response(text) for text in enhanced_prompt] | |
| def _get_gemma_prompt_embeds( | |
| components, | |
| prompt: str | list[str], | |
| max_sequence_length: int, | |
| device: torch.device, | |
| dtype: torch.dtype, | |
| ): | |
| # Mirrors `LTX2Pipeline._get_gemma_prompt_embeds`, minus the `num_videos_per_prompt` expansion. The whole text | |
| # stage stays at one row per prompt; `LTX2TextInputStep` expands the connector outputs to the effective batch at | |
| # the head of the denoise stage, so this stage's outputs are reusable across `num_videos_per_prompt` values. | |
| prompt = [prompt] if isinstance(prompt, str) else prompt | |
| # Gemma expects left padding for chat-style prompts. | |
| components.tokenizer.padding_side = "left" | |
| if components.tokenizer.pad_token is None: | |
| components.tokenizer.pad_token = components.tokenizer.eos_token | |
| prompt = [p.strip() for p in prompt] | |
| text_inputs = components.tokenizer( | |
| prompt, | |
| padding="max_length", | |
| max_length=max_sequence_length, | |
| truncation=True, | |
| add_special_tokens=True, | |
| return_tensors="pt", | |
| ) | |
| text_input_ids = text_inputs.input_ids.to(device) | |
| prompt_attention_mask = text_inputs.attention_mask.to(device) | |
| text_encoder_outputs = components.text_encoder( | |
| input_ids=text_input_ids, attention_mask=prompt_attention_mask, output_hidden_states=True | |
| ) | |
| # Stack all hidden-state layers into the packed per-layer representation the connectors expect. | |
| text_encoder_hidden_states = torch.stack(text_encoder_outputs.hidden_states, dim=-1) | |
| prompt_embeds = text_encoder_hidden_states.flatten(2, 3).to(dtype=dtype) # pack to 3D | |
| return prompt_embeds, prompt_attention_mask | |
| class LTX2PromptEnhancerStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Text-to-video prompt enhancer step. Rewrites `prompt` into a detailed caption using the dedicated " | |
| "LTX-2.5 `prompt_enhancer` (a Gemma conditional-generation model) and a system prompt." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("prompt_enhancer", PreTrainedModel), | |
| ComponentSpec("processor", ProcessorMixin), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam.template("prompt", required=True), | |
| InputParam( | |
| "enable_prompt_enhancement", | |
| type_hint=bool, | |
| default=False, | |
| description=( | |
| "Whether to run the prompt enhancer. Opt-in, matching the Lightricks reference pipelines." | |
| ), | |
| ), | |
| InputParam( | |
| "system_prompt", | |
| type_hint=str, | |
| default=None, | |
| description="System prompt for enhancement. Defaults to `LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT`.", | |
| ), | |
| InputParam( | |
| "prompt_max_new_tokens", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "Maximum number of new tokens to generate during prompt enhancement. Defaults to 600, the " | |
| "LTX-2.5 Gemma-4 enhancer's budget." | |
| ), | |
| ), | |
| InputParam( | |
| "prompt_enhancement_kwargs", | |
| type_hint=dict, | |
| default=None, | |
| description="Keyword arguments for the enhancer's `.generate` call. Defaults to greedy decoding.", | |
| ), | |
| InputParam( | |
| "prompt_enhancement_seed", | |
| type_hint=int, | |
| default=10, | |
| description="Random seed for prompt enhancement (inert under LTX-2.5's greedy decoding).", | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam("prompt", type_hint=list, description="The prompt(s) after prompt-enhancer rewriting."), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| if not block_state.enable_prompt_enhancement: | |
| self.set_block_state(state, block_state) # leave prompt unchanged | |
| return components, state | |
| if getattr(components, "prompt_enhancer", None) is None: | |
| raise ValueError( | |
| "`enable_prompt_enhancement=True` but no `prompt_enhancer` component is loaded. Load a " | |
| "`prompt_enhancer` (and `processor`), or set `enable_prompt_enhancement=False`." | |
| ) | |
| system_prompt = block_state.system_prompt or LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT | |
| block_state.prompt = _enhance_prompt( | |
| components=components, | |
| prompt=block_state.prompt, | |
| system_prompt=system_prompt, | |
| image=None, | |
| max_new_tokens=block_state.prompt_max_new_tokens, | |
| seed=block_state.prompt_enhancement_seed, | |
| generator=block_state.generator, | |
| generation_kwargs=block_state.prompt_enhancement_kwargs, | |
| device=components._execution_device, | |
| ) | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2ImageToVideoPromptEnhancerStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Image-to-video prompt enhancer step. Rewrites `prompt` into a detailed caption grounded in the reference " | |
| "`image`, using the dedicated LTX-2.5 `prompt_enhancer` and a system prompt." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("prompt_enhancer", PreTrainedModel), | |
| ComponentSpec("processor", ProcessorMixin), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam.template("prompt", required=True), | |
| InputParam.template("image", required=True), | |
| InputParam( | |
| "enable_prompt_enhancement", | |
| type_hint=bool, | |
| default=False, | |
| description=( | |
| "Whether to run the prompt enhancer. Opt-in, matching the Lightricks reference pipelines." | |
| ), | |
| ), | |
| InputParam( | |
| "system_prompt", | |
| type_hint=str, | |
| default=None, | |
| description="System prompt for enhancement. Defaults to `LTX2_5_I2V_DEFAULT_SYSTEM_PROMPT`.", | |
| ), | |
| InputParam( | |
| "prompt_max_new_tokens", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "Maximum number of new tokens to generate during prompt enhancement. Defaults to 600, the " | |
| "LTX-2.5 Gemma-4 enhancer's budget." | |
| ), | |
| ), | |
| InputParam( | |
| "prompt_enhancement_kwargs", | |
| type_hint=dict, | |
| default=None, | |
| description="Keyword arguments for the enhancer's `.generate` call. Defaults to greedy decoding.", | |
| ), | |
| InputParam( | |
| "prompt_enhancement_seed", | |
| type_hint=int, | |
| default=10, | |
| description="Random seed for prompt enhancement (inert under LTX-2.5's greedy decoding).", | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam("prompt", type_hint=list, description="The prompt(s) after prompt-enhancer rewriting."), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| if not block_state.enable_prompt_enhancement: | |
| self.set_block_state(state, block_state) # leave prompt unchanged | |
| return components, state | |
| if getattr(components, "prompt_enhancer", None) is None: | |
| raise ValueError( | |
| "`enable_prompt_enhancement=True` but no `prompt_enhancer` component is loaded. Load a " | |
| "`prompt_enhancer` (and `processor`), or set `enable_prompt_enhancement=False`." | |
| ) | |
| system_prompt = block_state.system_prompt or LTX2_5_I2V_DEFAULT_SYSTEM_PROMPT | |
| block_state.prompt = _enhance_prompt( | |
| components=components, | |
| prompt=block_state.prompt, | |
| system_prompt=system_prompt, | |
| image=block_state.image, | |
| max_new_tokens=block_state.prompt_max_new_tokens, | |
| seed=block_state.prompt_enhancement_seed, | |
| generator=block_state.generator, | |
| generation_kwargs=block_state.prompt_enhancement_kwargs, | |
| device=components._execution_device, | |
| ) | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2ConditionPromptEnhancerStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Condition prompt enhancer step. Rewrites `prompt` into a detailed caption using the dedicated LTX-2.5 " | |
| "`prompt_enhancer`, grounded in the first `PIL.Image.Image` frame found in `conditions` when there is " | |
| "one, and text-only otherwise. Reference conditions are never used for enhancement." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("prompt_enhancer", PreTrainedModel), | |
| ComponentSpec("processor", ProcessorMixin), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam.template("prompt", required=True), | |
| InputParam( | |
| "conditions", | |
| type_hint=list, | |
| default=None, | |
| description=( | |
| "`LTX2VideoCondition` (or list of them) placing image/video conditions at latent frame indices " | |
| "of the generated video." | |
| ), | |
| ), | |
| InputParam( | |
| "enable_prompt_enhancement", | |
| type_hint=bool, | |
| default=False, | |
| description=( | |
| "Whether to run the prompt enhancer. Opt-in, matching the Lightricks reference pipelines." | |
| ), | |
| ), | |
| InputParam( | |
| "system_prompt", | |
| type_hint=str, | |
| default=None, | |
| description=( | |
| "System prompt for enhancement. Defaults to `LTX2_5_I2V_DEFAULT_SYSTEM_PROMPT` when a " | |
| "`PIL.Image.Image` condition frame is available, else `LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT`." | |
| ), | |
| ), | |
| InputParam( | |
| "prompt_max_new_tokens", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "Maximum number of new tokens to generate during prompt enhancement. Defaults to 600, the " | |
| "LTX-2.5 Gemma-4 enhancer's budget." | |
| ), | |
| ), | |
| InputParam( | |
| "prompt_enhancement_kwargs", | |
| type_hint=dict, | |
| default=None, | |
| description="Keyword arguments for the enhancer's `.generate` call. Defaults to greedy decoding.", | |
| ), | |
| InputParam( | |
| "prompt_enhancement_seed", | |
| type_hint=int, | |
| default=10, | |
| description="Random seed for prompt enhancement (inert under LTX-2.5's greedy decoding).", | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam("prompt", type_hint=list, description="The prompt(s) after prompt-enhancer rewriting."), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| if not block_state.enable_prompt_enhancement: | |
| self.set_block_state(state, block_state) # leave prompt unchanged | |
| return components, state | |
| if getattr(components, "prompt_enhancer", None) is None: | |
| raise ValueError( | |
| "`enable_prompt_enhancement=True` but no `prompt_enhancer` component is loaded. Load a " | |
| "`prompt_enhancer` (and `processor`), or set `enable_prompt_enhancement=False`." | |
| ) | |
| conditions = block_state.conditions | |
| if conditions is None: | |
| conditions = [] | |
| elif isinstance(conditions, LTX2VideoCondition): | |
| conditions = [conditions] | |
| # First PIL frame across the conditions grounds the enhancement, matching `LTX2ConditionPipeline`. | |
| enhancement_image = None | |
| for condition in conditions: | |
| frames = condition.frames | |
| if isinstance(frames, PIL.Image.Image): | |
| enhancement_image = frames | |
| break | |
| if isinstance(frames, list) and len(frames) > 0 and isinstance(frames[0], PIL.Image.Image): | |
| enhancement_image = frames[0] | |
| break | |
| system_prompt = block_state.system_prompt | |
| if system_prompt is None: | |
| system_prompt = ( | |
| LTX2_5_I2V_DEFAULT_SYSTEM_PROMPT if enhancement_image is not None else LTX2_5_T2V_DEFAULT_SYSTEM_PROMPT | |
| ) | |
| block_state.prompt = _enhance_prompt( | |
| components=components, | |
| prompt=block_state.prompt, | |
| system_prompt=system_prompt, | |
| image=enhancement_image, | |
| max_new_tokens=block_state.prompt_max_new_tokens, | |
| seed=block_state.prompt_enhancement_seed, | |
| generator=block_state.generator, | |
| generation_kwargs=block_state.prompt_enhancement_kwargs, | |
| device=components._execution_device, | |
| ) | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2TextEncoderStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Text encoder step. Encodes `prompt` and `negative_prompt` into packed per-layer Gemma hidden states " | |
| "that the connectors adapt for the video and audio branches, and reports the prompt count (`batch_size`) " | |
| "and embedding `dtype`." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| # No `guider`: LTX-2 applies CFG (+ STG + modality-isolation) manually in the denoise loop, so the encoder | |
| # always produces both conditional and unconditional embeddings and the denoiser decides what to use. | |
| return [ | |
| ComponentSpec("text_encoder", PreTrainedModel), | |
| ComponentSpec("tokenizer", PreTrainedTokenizerBase), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam.template("prompt"), | |
| InputParam.template("negative_prompt"), | |
| InputParam.template("max_sequence_length", default=1024), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam( | |
| "prompt_embeds", | |
| type_hint=torch.Tensor, | |
| description="Packed per-layer Gemma hidden states for the prompt.", | |
| ), | |
| OutputParam( | |
| "prompt_attention_mask", | |
| type_hint=torch.Tensor, | |
| description="Binary attention mask for `prompt_embeds`.", | |
| ), | |
| OutputParam( | |
| "negative_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| description="Packed per-layer Gemma hidden states for the negative prompt.", | |
| ), | |
| OutputParam( | |
| "negative_prompt_attention_mask", | |
| type_hint=torch.Tensor, | |
| description="Binary attention mask for `negative_prompt_embeds`.", | |
| ), | |
| OutputParam( | |
| "batch_size", | |
| type_hint=int, | |
| description="The number of prompts being denoised (before per-prompt expansion).", | |
| ), | |
| OutputParam("dtype", type_hint=torch.dtype, description="The dtype of the prompt embeddings."), | |
| ] | |
| def check_inputs(block_state): | |
| if block_state.prompt is not None and not isinstance(block_state.prompt, (str, list)): | |
| raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(block_state.prompt)}") | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| self.check_inputs(block_state) | |
| device = components._execution_device | |
| dtype = components.text_encoder.dtype | |
| max_sequence_length = block_state.max_sequence_length | |
| prompt = [block_state.prompt] if isinstance(block_state.prompt, str) else block_state.prompt | |
| block_state.prompt_embeds, block_state.prompt_attention_mask = _get_gemma_prompt_embeds( | |
| components, prompt, max_sequence_length, device, dtype | |
| ) | |
| negative_prompt = block_state.negative_prompt or "" | |
| negative_prompt = len(prompt) * [negative_prompt] if isinstance(negative_prompt, str) else negative_prompt | |
| block_state.negative_prompt_embeds, block_state.negative_prompt_attention_mask = _get_gemma_prompt_embeds( | |
| components, negative_prompt, max_sequence_length, device, dtype | |
| ) | |
| block_state.batch_size = block_state.prompt_embeds.shape[0] | |
| block_state.dtype = block_state.prompt_embeds.dtype | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2TextConnectorStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Connector step. Adapts the Gemma hidden states into the separate video- and audio-branch text " | |
| "conditioning consumed by the transformer, for both the conditional and unconditional prompts." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("connectors", LTX2TextConnectors), | |
| # Declared only to read `padding_side` (used by the LTX-2.0 connector branch); LTX-2.5 defaults to "left". | |
| ComponentSpec("tokenizer", PreTrainedTokenizerBase), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam("prompt_embeds", type_hint=torch.Tensor, required=True), | |
| InputParam("prompt_attention_mask", type_hint=torch.Tensor, required=True), | |
| InputParam("negative_prompt_embeds", type_hint=torch.Tensor, required=True), | |
| InputParam("negative_prompt_attention_mask", type_hint=torch.Tensor, required=True), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| # Plain, explicitly-named cond/uncond outputs: the denoiser reads them by name through its guider | |
| # `guider_input_fields` map (transformer arg -> per-pass block-state attribute names), not via the | |
| # `denoiser_input_fields` tag, so they are intentionally not tagged here. | |
| return [ | |
| OutputParam( | |
| "connector_prompt_embeds", type_hint=torch.Tensor, description="Video-branch text conditioning (cond)." | |
| ), | |
| OutputParam( | |
| "connector_audio_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| description="Audio-branch text conditioning (cond).", | |
| ), | |
| OutputParam( | |
| "connector_attention_mask", type_hint=torch.Tensor, description="Binary text attention mask (cond)." | |
| ), | |
| OutputParam( | |
| "negative_connector_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| description="Video-branch text conditioning (uncond).", | |
| ), | |
| OutputParam( | |
| "negative_connector_audio_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| description="Audio-branch text conditioning (uncond).", | |
| ), | |
| OutputParam( | |
| "negative_connector_attention_mask", | |
| type_hint=torch.Tensor, | |
| description="Binary text attention mask (uncond).", | |
| ), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| padding_side = components.tokenizer.padding_side | |
| # Run the connector once on the CFG-concatenated `[uncond, cond]` batch, matching the standard pipeline | |
| # (`LTX2Pipeline` concatenates before the single `self.connectors(...)` call). The connector is applied per | |
| # batch element, so cond/uncond are mathematically independent either way, but a single batched call keeps the | |
| # results bitwise-identical to the standard pipeline: the connector's GEMM/attention kernels round the same for | |
| # a given row only at batch >= 2, so running the branches separately would diverge by ~1e-6 at batch size 1. | |
| num_negative = block_state.negative_prompt_embeds.shape[0] | |
| prompt_embeds = torch.cat([block_state.negative_prompt_embeds, block_state.prompt_embeds], dim=0) | |
| prompt_attention_mask = torch.cat( | |
| [block_state.negative_prompt_attention_mask, block_state.prompt_attention_mask], dim=0 | |
| ) | |
| connector_prompt_embeds, connector_audio_prompt_embeds, connector_attention_mask = components.connectors( | |
| prompt_embeds, prompt_attention_mask, padding_side=padding_side | |
| ) | |
| # Split back into uncond (first `num_negative`) and cond (rest). | |
| block_state.negative_connector_prompt_embeds = connector_prompt_embeds[:num_negative] | |
| block_state.negative_connector_audio_prompt_embeds = connector_audio_prompt_embeds[:num_negative] | |
| block_state.negative_connector_attention_mask = connector_attention_mask[:num_negative] | |
| block_state.connector_prompt_embeds = connector_prompt_embeds[num_negative:] | |
| block_state.connector_audio_prompt_embeds = connector_audio_prompt_embeds[num_negative:] | |
| block_state.connector_attention_mask = connector_attention_mask[num_negative:] | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2DurationStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Predicts `num_frames` from the connector text conditioning using the `duration_head`, producing a " | |
| "concrete frame count snapped to the VAE's temporal grid. Run only when `num_frames` was omitted (see " | |
| "`LTX2AutoDurationStep`)." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ComponentSpec("duration_head", LTX2DurationHead)] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam( | |
| "min_seconds", | |
| type_hint=float, | |
| default=1.0, | |
| description="Lower bound on the auto-predicted duration.", | |
| ), | |
| InputParam( | |
| "max_seconds", | |
| type_hint=float, | |
| default=20.0, | |
| description="Upper bound on the auto-predicted duration. Must be strictly greater than `min_seconds`.", | |
| ), | |
| InputParam( | |
| "frame_rate", type_hint=float, default=24.0, description="Frames per second of the generated video." | |
| ), | |
| InputParam( | |
| "connector_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| required=True, | |
| description="Video-branch text conditioning from the connector (positive prompt).", | |
| ), | |
| InputParam( | |
| "connector_audio_prompt_embeds", | |
| type_hint=torch.Tensor, | |
| required=True, | |
| description="Audio-branch text conditioning from the connector (positive prompt).", | |
| ), | |
| InputParam( | |
| "batch_size", | |
| type_hint=int, | |
| required=True, | |
| description="The number of prompts being denoised, used to expand conditioning per prompt.", | |
| ), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam("num_frames", type_hint=int, description="The predicted number of frames to generate."), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| if getattr(components, "duration_head", None) is None: | |
| raise ValueError( | |
| "`num_frames` was omitted so the duration head would auto-predict, but no `duration_head` component " | |
| "is loaded (the duration head ships from LTX-2.5 checkpoints onward). Load a `duration_head`, or pass " | |
| "`num_frames` as an integer." | |
| ) | |
| # The head predicts one duration; prompts with different natural lengths cannot share a single frame count. | |
| if block_state.batch_size > 1: | |
| raise ValueError( | |
| f"`num_frames` was omitted so the duration head would auto-predict, but {block_state.batch_size} " | |
| "prompts were supplied. The duration head predicts one duration -- run one prompt at a time, or pass " | |
| "`num_frames` as an integer." | |
| ) | |
| if block_state.min_seconds >= block_state.max_seconds: | |
| raise ValueError( | |
| f"`min_seconds` ({block_state.min_seconds}) must be less than `max_seconds` " | |
| f"({block_state.max_seconds}). A collapsed range leaves no room for a prediction, and cannot " | |
| "generally be satisfied by a frame count on the VAE's temporal grid." | |
| ) | |
| # `connector_prompt_embeds` is the positive (conditional) conditioning, one row per prompt (per-prompt | |
| # expansion happens later, in the denoise stage), and `batch_size > 1` was rejected above -- so it is a | |
| # single row here. | |
| block_state.num_frames = components.duration_head.predict_num_frames( | |
| block_state.connector_prompt_embeds, | |
| block_state.connector_audio_prompt_embeds, | |
| frame_rate=block_state.frame_rate, | |
| temporal_compression_ratio=components.vae_temporal_compression_ratio, | |
| min_seconds=block_state.min_seconds, | |
| max_seconds=block_state.max_seconds, | |
| ) | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| # Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_img2img.retrieve_latents | |
| def retrieve_latents( | |
| encoder_output: torch.Tensor, generator: torch.Generator | None = None, sample_mode: str = "sample" | |
| ): | |
| if hasattr(encoder_output, "latent_dist") and sample_mode == "sample": | |
| return encoder_output.latent_dist.sample(generator) | |
| elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax": | |
| return encoder_output.latent_dist.mode() | |
| elif hasattr(encoder_output, "latents"): | |
| return encoder_output.latents | |
| else: | |
| raise AttributeError("Could not access latents of provided encoder_output") | |
| def _normalize_latents( | |
| latents: torch.Tensor, latents_mean: torch.Tensor, latents_std: torch.Tensor, scaling_factor: float = 1.0 | |
| ) -> torch.Tensor: | |
| # Normalize video latents across the channel dimension [B, C, F, H, W]. | |
| latents_mean = latents_mean.view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) | |
| latents_std = latents_std.view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) | |
| latents = (latents - latents_mean) * scaling_factor / latents_std | |
| return latents | |
| def _pack_latents(latents: torch.Tensor, patch_size: int = 1, patch_size_t: int = 1) -> torch.Tensor: | |
| batch_size, num_channels, num_frames, height, width = latents.shape | |
| latents = latents.reshape( | |
| batch_size, | |
| -1, | |
| num_frames // patch_size_t, | |
| patch_size_t, | |
| height // patch_size, | |
| patch_size, | |
| width // patch_size, | |
| patch_size, | |
| ) | |
| latents = latents.permute(0, 2, 4, 6, 1, 3, 5, 7).flatten(4, 7).flatten(1, 3) | |
| return latents | |
| def _downsample_mask_to_latent( | |
| mask: torch.Tensor, latent_num_frames: int, latent_height: int, latent_width: int | |
| ) -> torch.Tensor: | |
| """ | |
| Downsample a pixel-space attention mask of shape `(B, 1, F, H, W)` (values in `[0, 1]`) to a flattened per-token | |
| latent-space mask of shape `(B, latent_num_frames * latent_height * latent_width)`. Spatial downsampling is area | |
| interpolation per frame; temporal downsampling is causal (the first frame is kept as-is). | |
| """ | |
| if mask.ndim != 5 or mask.shape[1] != 1: | |
| raise ValueError(f"Expected `conditioning_attention_mask` of shape (B, 1, F, H, W), got {tuple(mask.shape)}.") | |
| b, _, f_pix, _, _ = mask.shape | |
| mask_2d = mask.reshape(b * f_pix, 1, mask.shape[-2], mask.shape[-1]) | |
| spatial_down = torch.nn.functional.interpolate(mask_2d, size=(latent_height, latent_width), mode="area") | |
| spatial_down = spatial_down.reshape(b, 1, f_pix, latent_height, latent_width) | |
| first_frame = spatial_down[:, :, :1, :, :] | |
| if f_pix > 1 and latent_num_frames > 1: | |
| t = (f_pix - 1) // (latent_num_frames - 1) | |
| if (f_pix - 1) % (latent_num_frames - 1) != 0: | |
| raise ValueError( | |
| f"Pixel frames ({f_pix}) not compatible with latent frames ({latent_num_frames}): " | |
| f"(f_pix - 1) must be divisible by (latent_num_frames - 1)." | |
| ) | |
| rest = spatial_down[:, :, 1:, :, :] | |
| rest = rest.reshape(b, 1, latent_num_frames - 1, t, latent_height, latent_width).mean(dim=3) | |
| latent_mask = torch.cat([first_frame, rest], dim=2) | |
| else: | |
| latent_mask = first_frame | |
| return latent_mask.reshape(b, latent_num_frames * latent_height * latent_width) | |
| class LTX2VaeEncoderStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return "VAE encoder step that encodes the input `image` into normalized latents for image-to-video generation." | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("vae", AutoencoderKLLTX2Video), | |
| # Only used to resolve the default `image_crf` from the text-encoder generation. | |
| ComponentSpec("text_encoder", PreTrainedModel), | |
| ComponentSpec( | |
| "video_processor", | |
| VideoProcessor, | |
| config=FrozenDict({"vae_scale_factor": 32, "resample": "bilinear"}), | |
| default_creation_method="from_config", | |
| ), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam.template("image", required=True), | |
| InputParam.template("height", default=512), | |
| InputParam.template("width", default=704), | |
| InputParam( | |
| "image_crf", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "H.264 CRF used to re-compress the conditioning `image` before VAE encode, matching the " | |
| "compression the model was trained against. `None` (default) resolves from the text-encoder " | |
| "generation (33 through LTX-2.3, 18 for LTX-2.5). Pass `0` to skip re-compression. Requires a " | |
| "`PIL.Image.Image` when re-compression runs." | |
| ), | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam( | |
| "image_latents", | |
| type_hint=torch.Tensor, | |
| description="Normalized image latents (a single latent frame) for image-to-video conditioning.", | |
| ), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| device = components._execution_device | |
| image = block_state.image | |
| if not isinstance(image, torch.Tensor): | |
| # H.264 re-compress before resize/normalize (ltx-pipelines `load_image_and_preprocess`). | |
| crf = ( | |
| block_state.image_crf | |
| if block_state.image_crf is not None | |
| else resolve_default_image_crf(components.text_encoder) | |
| ) | |
| if crf != 0: | |
| if not isinstance(image, PIL.Image.Image): | |
| raise ValueError( | |
| f"`image_crf` re-compression requires a `PIL.Image.Image` input, got {type(image)}. " | |
| "Pass a PIL image, or set `image_crf=0` to skip re-compression." | |
| ) | |
| image = PIL.Image.fromarray(apply_image_conditioning_crf(np.array(image.convert("RGB")), crf)) | |
| image = components.video_processor.preprocess(image, height=block_state.height, width=block_state.width) | |
| image = image.to(device=device, dtype=torch.float32) | |
| vae_dtype = components.vae.dtype | |
| if isinstance(block_state.generator, list): | |
| init_latents = [ | |
| retrieve_latents( | |
| components.vae.encode(image[i].unsqueeze(0).unsqueeze(2).to(vae_dtype)), | |
| block_state.generator[i], | |
| "argmax", | |
| ) | |
| for i in range(image.shape[0]) | |
| ] | |
| else: | |
| init_latents = [ | |
| retrieve_latents( | |
| components.vae.encode(img.unsqueeze(0).unsqueeze(2).to(vae_dtype)), block_state.generator, "argmax" | |
| ) | |
| for img in image | |
| ] | |
| init_latents = torch.cat(init_latents, dim=0).to(torch.float32) | |
| block_state.image_latents = _normalize_latents(init_latents, components.latents_mean, components.latents_std) | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2ConditionEncoderStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Condition encoder step. Resizes and center-crops each frame condition to the target resolution, H.264 " | |
| "re-compresses single-frame image conditions at the model CRF, trims them to the VAE's temporal grid, " | |
| "and VAE-encodes them into normalized latents for `LTX2ConditionPrepareLatentsStep`." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| # No `video_processor`: `VideoProcessor.preprocess_video` resizes through `PIL.Image.resize`, which | |
| # anti-alias prefilters on downscale. The reference uses a plain `F.interpolate`, reproduced in `__call__`. | |
| return [ | |
| ComponentSpec("vae", AutoencoderKLLTX2Video), | |
| # Only used to resolve a condition's default `crf` from the text-encoder generation. | |
| ComponentSpec("text_encoder", PreTrainedModel), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam( | |
| "conditions", | |
| type_hint=list, | |
| default=None, | |
| description=( | |
| "`LTX2VideoCondition` (or list of them) placing image/video conditions at latent frame indices " | |
| "of the generated video." | |
| ), | |
| ), | |
| InputParam.template("height", default=512), | |
| InputParam.template("width", default=704), | |
| InputParam( | |
| "num_frames", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "The number of frames in the generated video. Omit to auto-predict via the `duration_head` " | |
| "(see `LTX2AutoDurationStep`)." | |
| ), | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam( | |
| "condition_latents", | |
| type_hint=list, | |
| description="Per-condition normalized VAE latents of shape [1, C, F, H, W].", | |
| ), | |
| OutputParam("condition_strengths", type_hint=list, description="Per-condition conditioning strengths."), | |
| OutputParam( | |
| "condition_indices", | |
| type_hint=list, | |
| description="Per-condition latent frame index at which the condition is applied.", | |
| ), | |
| OutputParam( | |
| "condition_pixel_frames", | |
| type_hint=list, | |
| description=( | |
| "Per-condition trimmed pixel frame count, used to clamp the temporal extent of single-frame " | |
| "keyframe coordinates." | |
| ), | |
| ), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| device = components._execution_device | |
| conditions = block_state.conditions | |
| if conditions is None: | |
| conditions = [] | |
| elif isinstance(conditions, LTX2VideoCondition): | |
| conditions = [conditions] | |
| # First block on the condition path that needs a concrete frame count. In the condition blockset | |
| # `LTX2AutoDurationStep` has already resolved it; the in-context blockset has no duration head, so the | |
| # caller must supply it. | |
| if block_state.num_frames is None: | |
| raise ValueError( | |
| "`num_frames` must be a concrete integer here. Pass `num_frames`, or use a blockset that runs " | |
| "`LTX2AutoDurationStep` with a loaded `duration_head` (LTX-2.5 checkpoints onward) to auto-predict it." | |
| ) | |
| height, width, num_frames = block_state.height, block_state.width, block_state.num_frames | |
| frame_scale_factor = components.vae_temporal_compression_ratio | |
| latent_num_frames = (num_frames - 1) // frame_scale_factor + 1 | |
| generator = block_state.generator[0] if isinstance(block_state.generator, list) else block_state.generator | |
| condition_latents, condition_strengths, condition_indices, condition_pixel_frames = [], [], [], [] | |
| for i, condition in enumerate(conditions): | |
| # Channels-last (F, H, W, C) array, the layout the resize below expects. | |
| if isinstance(condition.frames, PIL.Image.Image): | |
| arr = np.array(condition.frames.convert("RGB"))[None] | |
| elif isinstance(condition.frames, list) and all(isinstance(f, PIL.Image.Image) for f in condition.frames): | |
| arr = np.stack([np.array(f.convert("RGB")) for f in condition.frames]) | |
| elif isinstance(condition.frames, np.ndarray): | |
| arr = condition.frames if condition.frames.ndim == 4 else condition.frames[None] | |
| elif isinstance(condition.frames, torch.Tensor): | |
| t = condition.frames if condition.frames.ndim == 4 else condition.frames.unsqueeze(0) | |
| # Video tensors are (F, C, H, W); convert to channels-last for the resize. | |
| arr = t.detach().cpu().permute(0, 2, 3, 1).numpy() | |
| else: | |
| raise TypeError(f"Unsupported `frames` type for condition {i}: {type(condition.frames)}") | |
| # Single-frame image keyframes are H.264 re-compressed at the model CRF (ltx-pipelines | |
| # `ImageConditioner.resolve_crf` + `media_io.preprocess`). Multi-frame video conditions are not. | |
| if arr.shape[0] == 1: | |
| crf = ( | |
| condition.crf if condition.crf is not None else resolve_default_image_crf(components.text_encoder) | |
| ) | |
| if crf != 0 and arr.dtype != np.uint8: | |
| raise ValueError( | |
| f"Image conditioning CRF expects a uint8 RGB frame, got dtype={arr.dtype}. " | |
| "Pass a PIL image / uint8 array, or set `crf=0` on the condition to skip re-compression." | |
| ) | |
| arr = apply_image_conditioning_crf(arr[0], crf)[None] | |
| src_h, src_w = arr.shape[1], arr.shape[2] | |
| num_cond_frames = arr.shape[0] | |
| pixels = torch.from_numpy(np.ascontiguousarray(arr)).to(torch.float32) | |
| pixels = pixels.permute(3, 0, 1, 2).unsqueeze(0).to(device) # (1, C, F, H, W) | |
| # Resize so the longer side fills the target, then center-crop to exactly (height, width). | |
| scale = max(height / src_h, width / src_w) | |
| new_h = math.ceil(src_h * scale) | |
| new_w = math.ceil(src_w * scale) | |
| pixels = pixels.permute(0, 2, 1, 3, 4).reshape(num_cond_frames, 3, src_h, src_w) | |
| pixels = torch.nn.functional.interpolate(pixels, size=(new_h, new_w), mode="bilinear", align_corners=False) | |
| top = (new_h - height) // 2 | |
| left = (new_w - width) // 2 | |
| pixels = pixels[:, :, top : top + height, left : left + width] | |
| pixels = pixels.reshape(1, num_cond_frames, 3, height, width).permute(0, 2, 1, 3, 4) | |
| condition_pixels = pixels / 127.5 - 1.0 # [0, 255] -> [-1, 1] (VAE input convention) | |
| # `index` is interpreted as a latent index, following the reference. Negative indices wrap. | |
| latent_start_idx = condition.index | |
| if latent_start_idx < 0: | |
| latent_start_idx = latent_start_idx % latent_num_frames | |
| if latent_start_idx >= latent_num_frames: | |
| logger.warning( | |
| f"The starting latent index {latent_start_idx} of condition {i} is too big for the specified" | |
| f" number of latent frames {latent_num_frames}. This condition will be skipped." | |
| ) | |
| continue | |
| # Trim the conditioning sequence to a multiple of the VAE temporal scale factor, plus one. | |
| start_idx = max((latent_start_idx - 1) * frame_scale_factor + 1, 0) | |
| truncated_cond_frames = min(condition_pixels.size(2), num_frames - start_idx) | |
| truncated_cond_frames = (truncated_cond_frames - 1) // frame_scale_factor * frame_scale_factor + 1 | |
| condition_pixels = condition_pixels[:, :, :truncated_cond_frames] | |
| latents = retrieve_latents( | |
| components.vae.encode(condition_pixels.to(dtype=components.vae.dtype, device=device)), | |
| generator=generator, | |
| sample_mode="argmax", | |
| ) | |
| latents = _normalize_latents(latents, components.latents_mean, components.latents_std) | |
| condition_latents.append(latents.to(device=device, dtype=torch.float32)) | |
| condition_strengths.append(condition.strength) | |
| condition_indices.append(latent_start_idx) | |
| condition_pixel_frames.append(truncated_cond_frames) | |
| block_state.condition_latents = condition_latents | |
| block_state.condition_strengths = condition_strengths | |
| block_state.condition_indices = condition_indices | |
| block_state.condition_pixel_frames = condition_pixel_frames | |
| self.set_block_state(state, block_state) | |
| return components, state | |
| class LTX2ReferenceEncoderStep(ModularPipelineBlocks): | |
| model_name = "ltx2" | |
| def description(self) -> str: | |
| return ( | |
| "Reference encoder step for in-context (IC-LoRA) generation. Preprocesses each reference video to the " | |
| "(optionally downscaled) target resolution, VAE-encodes and packs it into tokens, and computes the " | |
| "positional coordinates that map those tokens into the target coordinate space. When " | |
| "`conditioning_attention_strength < 1.0` or a pixel-space `conditioning_attention_mask` is supplied it " | |
| "also produces the per-token cross-attention strengths driving the video self-attention mask." | |
| ) | |
| def expected_components(self) -> list[ComponentSpec]: | |
| return [ | |
| ComponentSpec("vae", AutoencoderKLLTX2Video), | |
| ComponentSpec("transformer", LTX2VideoTransformer3DModel), | |
| ComponentSpec( | |
| "video_processor", | |
| VideoProcessor, | |
| config=FrozenDict({"vae_scale_factor": 32, "resample": "bilinear"}), | |
| default_creation_method="from_config", | |
| ), | |
| ] | |
| def inputs(self) -> list[InputParam]: | |
| return [ | |
| InputParam( | |
| "reference_conditions", | |
| type_hint=list, | |
| required=True, | |
| description=( | |
| "`LTX2ReferenceCondition` (or list of them) whose videos are encoded into extra latent tokens " | |
| "the IC-LoRA adapter attends to." | |
| ), | |
| ), | |
| InputParam( | |
| "reference_downscale_factor", | |
| type_hint=int, | |
| default=1, | |
| description=( | |
| "Ratio between the target and reference resolutions; 2 means the reference is preprocessed at " | |
| "half the target resolution. Spatial coordinates are scaled by this factor so the reference " | |
| "tokens land in the target coordinate space. Must match the factor the IC-LoRA was trained with." | |
| ), | |
| ), | |
| InputParam( | |
| "conditioning_attention_strength", | |
| type_hint=float, | |
| default=1.0, | |
| description=( | |
| "Scalar in [0, 1] controlling how strongly the noisy tokens and reference tokens attend to each " | |
| "other. 1.0 (default) leaves attention unmasked." | |
| ), | |
| ), | |
| InputParam( | |
| "conditioning_attention_mask", | |
| type_hint=torch.Tensor, | |
| default=None, | |
| description=( | |
| "Optional pixel-space mask of shape (1, 1, F, H, W) with values in [0, 1] giving spatially " | |
| "varying attention strength. Downsampled to the reference's latent grid and multiplied by " | |
| "`conditioning_attention_strength`." | |
| ), | |
| ), | |
| InputParam.template("height", default=512), | |
| InputParam.template("width", default=704), | |
| InputParam( | |
| "num_frames", | |
| type_hint=int, | |
| default=None, | |
| description=( | |
| "The number of frames in the generated video. Omit to auto-predict via the `duration_head` " | |
| "(see `LTX2AutoDurationStep`)." | |
| ), | |
| ), | |
| InputParam( | |
| "frame_rate", type_hint=float, default=24.0, description="Frames per second of the generated video." | |
| ), | |
| InputParam.template("generator"), | |
| ] | |
| def intermediate_outputs(self) -> list[OutputParam]: | |
| return [ | |
| OutputParam( | |
| "reference_latents", | |
| type_hint=torch.Tensor, | |
| description="Packed reference tokens of shape [1, total_reference_tokens, C].", | |
| ), | |
| OutputParam( | |
| "reference_coords", | |
| type_hint=torch.Tensor, | |
| description="RoPE coordinates for the reference tokens, of shape [1, 3, total_reference_tokens, 2].", | |
| ), | |
| OutputParam( | |
| "reference_token_counts", | |
| type_hint=list, | |
| description="Per-reference token counts, in `reference_conditions` order.", | |
| ), | |
| OutputParam( | |
| "reference_cross_mask", | |
| type_hint=torch.Tensor, | |
| description=( | |
| "Per-reference-token noisy<->reference attention strengths of shape [1, " | |
| "total_reference_tokens], or `None` when attention is left unmasked." | |
| ), | |
| ), | |
| ] | |
| def __call__(self, components, state: PipelineState) -> PipelineState: | |
| block_state = self.get_block_state(state) | |
| device = components._execution_device | |
| reference_conditions = block_state.reference_conditions | |
| if isinstance(reference_conditions, LTX2ReferenceCondition): | |
| reference_conditions = [reference_conditions] | |
| if len(reference_conditions) == 0: | |
| raise ValueError( | |
| "`reference_conditions` is empty, so there is nothing to encode. Omit it entirely for IC-LoRAs that " | |
| "take no reference video -- `LTX2AutoReferenceEncoderStep` then skips this step." | |
| ) | |
| downscale_factor = block_state.reference_downscale_factor | |
| ref_height = block_state.height // downscale_factor | |
| ref_width = block_state.width // downscale_factor | |
| strength = block_state.conditioning_attention_strength | |
| attention_mask = block_state.conditioning_attention_mask | |
| # An all-ones mask at full strength is the same as no mask, so only materialize one when it can bite. | |
| mask_needed = strength < 1.0 or attention_mask is not None | |
| generator = block_state.generator[0] if isinstance(block_state.generator, list) else block_state.generator | |
| all_latents, all_coords, all_cross_masks, token_counts = [], [], [], [] | |
| for ref_cond in reference_conditions: | |
| if isinstance(ref_cond.frames, PIL.Image.Image): | |
| video_like = [ref_cond.frames] | |
| elif isinstance(ref_cond.frames, np.ndarray) and ref_cond.frames.ndim == 3: | |
| video_like = np.expand_dims(ref_cond.frames, axis=0) | |
| elif isinstance(ref_cond.frames, torch.Tensor) and ref_cond.frames.ndim == 3: | |
| video_like = ref_cond.frames.unsqueeze(0) | |
| else: | |
| video_like = ref_cond.frames | |
| ref_pixels = components.video_processor.preprocess_video( | |
| video_like, ref_height, ref_width, resize_mode="crop" | |
| ) | |
| ref_pixels = ref_pixels[:, :, : block_state.num_frames] | |
| ref_pixels = ref_pixels.to(dtype=components.vae.dtype, device=device) | |
| ref_latent = retrieve_latents(components.vae.encode(ref_pixels), generator=generator, sample_mode="argmax") | |
| ref_latent = _normalize_latents(ref_latent, components.latents_mean, components.latents_std).to( | |
| device=device, dtype=torch.float32 | |
| ) | |
| _, _, ref_latent_frames, ref_latent_height, ref_latent_width = ref_latent.shape | |
| ref_latent_packed = _pack_latents( | |
| ref_latent, components.transformer_spatial_patch_size, components.transformer_temporal_patch_size | |
| ) | |
| # Coordinates are computed on the reference's own latent grid, then scaled spatially so the tokens map | |
| # into the target's coordinate space (preserving the positional relationship the IC-LoRA was trained on). | |
| ref_coords = components.transformer.rope.prepare_video_coords( | |
| batch_size=1, | |
| num_frames=ref_latent_frames, | |
| height=ref_latent_height, | |
| width=ref_latent_width, | |
| device=device, | |
| fps=block_state.frame_rate, | |
| ) | |
| if downscale_factor != 1: | |
| ref_coords[:, 1, :, :] = ref_coords[:, 1, :, :] * downscale_factor | |
| ref_coords[:, 2, :, :] = ref_coords[:, 2, :, :] * downscale_factor | |
| if mask_needed: | |
| if attention_mask is not None: | |
| ref_cross = _downsample_mask_to_latent( | |
| attention_mask, ref_latent_frames, ref_latent_height, ref_latent_width | |
| ).to(device=device, dtype=torch.float32) | |
| else: | |
| ref_cross = torch.ones((1, ref_latent_packed.shape[1]), device=device, dtype=torch.float32) | |
| all_cross_masks.append(ref_cross * strength) | |
| all_latents.append(ref_latent_packed) | |
| all_coords.append(ref_coords) | |
| token_counts.append(ref_latent_packed.shape[1]) | |
| block_state.reference_latents = torch.cat(all_latents, dim=1) | |
| block_state.reference_coords = torch.cat(all_coords, dim=2) | |
| block_state.reference_token_counts = token_counts | |
| block_state.reference_cross_mask = torch.cat(all_cross_masks, dim=1) if mask_needed else None | |
| self.set_block_state(state, block_state) | |
| return components, state | |