Add direct first/last-frame (fl2va) keyframe conditioning

Wire full fl2va into the direct H3 runtime so first/last keyframes flow
through VAE encode -> Qwen vision tokens -> DiT cond segments:

- vae_encoder.py: direct encoder-only H3 video VAE (causal 3D convs,
  reflect spatial padding, causal temporal padding, single-frame tap
  truncation, tiling, FP32 moments + mean/std normalization).
- qwen3vl_vision.py: Qwen3-VL-32B visual tower (27 blocks, 2D rotary,
  deepstack mergers) ported to match the Comfy reference exactly
  (head_dim=72, no-bias proj, LayerNorm blocks, split-half apply_rope),
  plus Qwen image preprocess, mrope ids/freqs, DiT token tags, keyframe
  resize (first=stretch / last=center cover-crop matching Comfy
  common_upscale), and build_fl2va_presentation.
- qwen3vl_text.py: split-half apply_rope, _embed_rows/_run_layers,
  optional mrope position_ids + DeepStack injection at the first three
  decoder layers at visual positions.
- packing.py: H3PromptPacker builds [text | cond | audio | video] with
  tag-run text spans, cond rows (first/last cond_t anchors,
  VISUAL_COND_TIMESTEP=0.999 noise augmentation via CPU-seeded RNG),
  three-timestep row table (t_row*3 + modality_tag), and rope positions.
- runtime.py: load VAE encoder + vision tower; generate() accepts
  first_frame/last_frame, builds the fl2va presentation, encodes keyframes,
  and passes text_token_tags/cond_latents/frame_count/seed to the sampler.
- sampler.py: thread pack kwargs + seed.
- serve_hot_runtime.py / direct_t2v_preview.py: /generate and
  --first-frame/--last-frame accept image paths or base64.
This commit is contained in:
Daniel Maddern 2026-08-19 20:17:41 +07:00
parent 8730920634
commit 807bd64a82
8 changed files with 1378 additions and 58 deletions

View file

@ -34,6 +34,13 @@ def _axis(dim: int, area: float) -> torch.Tensor:
return (torch.arange(count, dtype=torch.float64) * ratio / count + (1 - ratio) / 2) * 32
def _frame_positions(height: int, width: int) -> torch.Tensor:
"""(t ignored) area-normalized (h, w) grid of one latent frame's 2x2-patch rows."""
area = math.sqrt(height * width)
ys, xs = torch.meshgrid(_axis(height, area), _axis(width, area), indexing="ij")
return torch.stack((ys.flatten(), xs.flatten()), dim=-1) # [frame_rows, 2]
def _video_positions(frames: int, height: int, width: int, offset: float) -> torch.Tensor:
area = math.sqrt(height * width)
ys, xs = torch.meshgrid(_axis(height, area), _axis(width, area), indexing="ij")
@ -45,6 +52,18 @@ def _video_positions(frames: int, height: int, width: int, offset: float) -> tor
return result.reshape(-1, 3)
def _cond_positions(frames: int, cond_t: float, height: int, width: int) -> torch.Tensor:
spatial = _frame_positions(height, width)
g = torch.empty(spatial.shape[0], 3, dtype=torch.float64)
g[:, 0] = cond_t
g[:, 1:] = spatial
return g
def _video_t_spans(n: int) -> list[float]:
return [FRAME_RESCALE * FRAME_PER_TOKEN[k % 5] for k in range(n)]
def _audio_positions(steps: int, offset: float, width: int, height: int) -> torch.Tensor:
area = math.sqrt(height * width)
x_axis = _axis(width, area)
@ -65,6 +84,8 @@ class H3PromptPacker:
self.text_weight = checkpoint.tensor("condition_proj.weight", dtype=torch.bfloat16)
self.text_bias = checkpoint.tensor("condition_proj.bias", dtype=torch.bfloat16)
VISUAL_COND_TIMESTEP = 0.999
def __call__(
self,
text: torch.Tensor,
@ -72,35 +93,114 @@ class H3PromptPacker:
audio: torch.Tensor,
sigma: float | torch.Tensor,
model_timesteps: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, list[tuple[int, int, int]], tuple[int, int, int], tuple[int, int, int]]:
*,
text_token_tags: torch.Tensor | None = None,
cond_latents: list[torch.Tensor] | None = None,
frame_count: int | None = None,
seed: int = 0,
) -> tuple[torch.Tensor, torch.Tensor, list[tuple[int, int, int]], torch.Tensor, tuple[int, int, int], tuple[int, int, int]]:
"""Build ``[text | cond | audio | video]`` packed rows for (fl2va) H3.
``text`` is the refined text span (width 5376 when already refined, 5120
for raw Qwen states); ``text_token_tags`` is the per-token DiT modality tag
(1=text, 0=video over vision pads). ``cond_latents`` are normalized keyframe
latents ``[1,24,1,H/16,W/16]`` spliced right after the text as non-denoised
cond rows with their own near-1 timestep. Returns
``(hidden, times, segments, positions, video_seg, audio_seg)`` where
``segments`` rows are ``t_row*3 + modality_tag``.
"""
if text.shape[-1] == 5120:
text_rows = functional.linear(text[0].to(self.text_weight.dtype), self.text_weight, self.text_bias).to(torch.bfloat16)
elif text.shape[-1] == 5376:
text_rows = text[0].to(torch.bfloat16)
else:
raise ValueError("H3 text states must be Qwen 5120-wide or refined 5376-wide.")
text_length = text_rows.shape[0]
latent_t, latent_h, latent_w = video.shape[2], video.shape[-2], video.shape[-1]
frame_rows = (latent_h // 2) * (latent_w // 2)
video_rows = functional.linear(patchify_video(video.to(torch.bfloat16)).float(), self.video_weight, self.video_bias).to(torch.bfloat16)
audio_rows = functional.linear(pack_audio(audio.to(torch.bfloat16)).float(), self.audio_weight, self.audio_bias).to(torch.bfloat16)
text_length, audio_length = text_rows.shape[0], audio_rows.shape[0]
hidden = torch.cat((text_rows, audio_rows, video_rows))
cond_rows = None
if cond_latents:
cond_patches = []
# every cond video restarts the same CPU RNG stream (Comfy _cond_video_rows)
for idx, z in enumerate(cond_latents):
r = patchify_video(z.to(torch.float32))
if self.VISUAL_COND_TIMESTEP < 1.0:
gen = torch.Generator("cpu").manual_seed(int(seed))
noise = torch.randn(r.shape, generator=gen, dtype=torch.float32)
r = self.VISUAL_COND_TIMESTEP * r + (1.0 - self.VISUAL_COND_TIMESTEP) * noise.to(r.device)
cond_patches.append(r)
cond_rows = functional.linear(torch.cat(cond_patches, dim=0), self.video_weight.to(torch.float32), self.video_bias.to(torch.float32)).to(torch.bfloat16)
if model_timesteps is None:
video_sigma = torch.as_tensor(sigma, device=hidden.device, dtype=torch.float32).clamp(min=1e-6)
video_sigma = torch.as_tensor(sigma, device=text_rows.device, dtype=torch.float32).clamp(min=1e-6)
base = video_sigma / (12.0 + video_sigma * (1.0 - 12.0))
audio_sigma = 3.0 * base / (1.0 + (3.0 - 1.0) * base)
video_time, audio_time = (1.0 - video_sigma).item(), (1.0 - audio_sigma).item()
unique_times = sorted({video_time, audio_time})
else:
times_override = model_timesteps.to(device=hidden.device, dtype=torch.float32).flatten()
if times_override.numel() not in (1, 2):
raise ValueError("Prompt-only H3 expects one or two model timesteps.")
unique_times = times_override.tolist()
video_time, audio_time = unique_times[0], unique_times[-1]
row = {value: index for index, value in enumerate(unique_times)}
video_row, audio_row = row[video_time] * 3, row[audio_time] * 3
times = torch.tensor(unique_times, device=hidden.device, dtype=torch.float32)
positions = torch.cat((torch.stack((torch.arange(text_length, dtype=torch.float64), torch.zeros(text_length), torch.zeros(text_length)), dim=-1), _audio_positions(audio.shape[-1], float(text_length), video.shape[-1], video.shape[-2]), _video_positions(video.shape[2], video.shape[-2], video.shape[-1], float(text_length))))
block_video_segment = (text_length + audio_length, hidden.shape[0], video_row)
block_audio_segment = (text_length, text_length + audio_length, audio_row + 2)
final_video_segment = (text_length + audio_length, hidden.shape[0], row[video_time])
final_audio_segment = (text_length, text_length + audio_length, row[audio_time])
return hidden, times, [(0, text_length, video_row + 1), block_audio_segment, block_video_segment], positions, final_video_segment, final_audio_segment
times_override = model_timesteps.to(device=text_rows.device, dtype=torch.float32).flatten()
unique_override = sorted(times_override.tolist())
video_time, audio_time = unique_override[0], unique_override[-1]
has_vis_cond = cond_rows is not None
cond_time = max(video_time, self.VISUAL_COND_TIMESTEP)
unique_times = sorted({video_time, audio_time} | ({cond_time} if has_vis_cond else set()))
t_row = {value: index for index, value in enumerate(unique_times)}
times = torch.tensor(unique_times, device=text_rows.device, dtype=torch.float32)
# Assemble hidden in segment order: text | cond | audio | video.
parts = [text_rows]
offsets = [0]
for block in ((cond_rows, "cond"), (audio_rows, "audio"), (video_rows, "video")):
if block[0] is None:
continue
parts.append(block[0])
offsets.append(offsets[-1] + block[0].shape[0])
hidden = torch.cat(parts, dim=0)
audio_length = audio_rows.shape[0]
cond_length = cond_rows.shape[0] if cond_rows is not None else 0
# Positions: text rows, cond (first/last t anchors), audio, video.
text_positions = torch.stack((torch.arange(text_length, dtype=torch.float64), torch.zeros(text_length), torch.zeros(text_length)), dim=-1)
position_blocks = [text_positions]
if cond_rows is not None and cond_latents:
spans = _video_t_spans(latent_t)
cond_t_values = [
float(text_length) if idx == 0 else (float(text_length) + sum(spans) - FRAME_RESCALE if frame_count is not None and idx == len(cond_latents) - 1 else float(text_length))
for idx in range(len(cond_latents))
]
position_blocks.append(torch.cat([_cond_positions(frame_rows, cond_t, latent_h, latent_w) for cond_t in cond_t_values], dim=0))
position_blocks.append(_audio_positions(audio.shape[-1], float(text_length + cond_length), latent_w, latent_h))
position_blocks.append(_video_positions(latent_t, latent_h, latent_w, float(text_length + cond_length + audio_length)))
positions = torch.cat(position_blocks, dim=0)
# mod_segments: (start, stop, t_row*3 + tag).
segments: list[tuple[int, int, int]] = []
if text_token_tags is not None:
tags = text_token_tags.view(-1).tolist()
row_base = t_row[video_time] * 3
run_start = 0
for i in range(1, text_length + 1):
if i == text_length or tags[i] != tags[run_start]:
segments.append((run_start, i, row_base + int(tags[run_start])))
run_start = i
else:
segments.append((0, text_length, t_row[video_time] * 3 + 1))
cursor_start = text_length
if cond_rows is not None:
segments.append((cursor_start, cursor_start + cond_rows.shape[0], t_row[cond_time] * 3 + 0))
cursor_start += cond_rows.shape[0]
segments.append((cursor_start, cursor_start + audio_length, t_row[audio_time] * 3 + 2))
cursor_start += audio_length
video_start = cursor_start
video_stop = video_start + video_rows.shape[0]
segments.append((video_start, video_stop, t_row[video_time] * 3 + 0))
video_segment = (video_start, video_stop, t_row[video_time])
audio_start = text_length + cond_length
audio_segment = (audio_start, audio_start + audio_length, t_row[audio_time])
return hidden, times, segments, positions, video_segment, audio_segment

View file

@ -42,6 +42,24 @@ class _RMSNorm(nn.Module):
return F.rms_norm(x, self.weight.shape, weight=self.weight.to(x), eps=self.eps)
def _apply_rope(query: torch.Tensor, key: torch.Tensor, freqs) -> tuple[torch.Tensor, torch.Tensor]:
"""Direct PyTorch port of Comfy's `apply_rope` fed `freqs=(cos, sin, neg_sin)`.
``query``/``key`` are ``[batch, heads, seq, head_dim]``; ``freqs`` broadcast over
that shape. This is the exact split-half kernel the reference uses for both the
plain text rope and the Qwen3-VL interleaved mrope.
"""
cosine, sine, negative_sine = freqs
split = query.shape[-1] // 2
q = query * cosine
q[..., :split].addcmul_(query[..., split:], negative_sine)
q[..., split:].addcmul_(query[..., :split], sine)
k = key * cosine
k[..., :split].addcmul_(key[..., split:], negative_sine)
k[..., split:].addcmul_(key[..., :split], sine)
return q.to(query.dtype), k.to(key.dtype)
def _rope(query: torch.Tensor, key: torch.Tensor, theta: float) -> tuple[torch.Tensor, torch.Tensor]:
"""Direct PyTorch port of Comfy's text-only `precompute_freqs_cis` / `apply_rope`."""
sequence, head_dim = query.shape[-2:]
@ -54,16 +72,7 @@ def _rope(query: torch.Tensor, key: torch.Tensor, theta: float) -> tuple[torch.T
sine = embedding.sin().unsqueeze(1)
negative_sine = -sine[..., sine.shape[-1] // 2 :]
sine = sine[..., : sine.shape[-1] // 2]
query_output = query * cosine
split = query_output.shape[-1] // 2
query_output[..., :split].addcmul_(query[..., split:], negative_sine)
query_output[..., split:].addcmul_(query[..., :split], sine)
key_output = key * cosine
split = key_output.shape[-1] // 2
key_output[..., :split].addcmul_(key[..., split:], negative_sine)
key_output[..., split:].addcmul_(key[..., :split], sine)
return query_output.to(query.dtype), key_output.to(key.dtype)
return _apply_rope(query, key, (cosine, sine, negative_sine))
def _qwen_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
@ -105,7 +114,7 @@ class _Qwen3VLBlock(nn.Module):
self.up_proj = checkpoint.nvfp4_linear(f"{prefix}.mlp.up_proj", output_dtype=dtype)
self.down_proj = checkpoint.nvfp4_linear(f"{prefix}.mlp.down_proj", output_dtype=dtype)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
def forward(self, hidden_states: torch.Tensor, freqs_cis=None) -> torch.Tensor:
residual = hidden_states
x = self.input_layernorm(hidden_states)
batch, sequence, _ = x.shape
@ -114,7 +123,10 @@ class _Qwen3VLBlock(nn.Module):
value = self.v_proj(x).view(batch, sequence, self.config.num_key_value_heads, self.config.head_dim).transpose(1, 2)
query = self.q_norm(query)
key = self.k_norm(key)
query, key = _rope(query, key, self.config.rope_theta)
if freqs_cis is None:
query, key = _rope(query, key, self.config.rope_theta)
else:
query, key = _apply_rope(query, key, freqs_cis)
# Comfy selects its small-input SDPA path for Qwen, with an explicit causal mask.
causal_mask = torch.full(
(sequence, sequence),
@ -178,6 +190,44 @@ class Qwen3VL32BTextEncoder(nn.Module):
+ ", ".join(missing)
)
def _embed_rows(self, input_ids: torch.Tensor) -> torch.Tensor:
"""Scaled token embeds ``[batch, tokens, 5120]`` in fp32 (pre-decoder)."""
token_rows = F.embedding(input_ids.to(self.embed_tokens.device), self.embed_tokens).to(torch.float32)
token_scales = F.embedding(input_ids.to(self.embed_scale.device), self.embed_scale)
return (token_rows * token_scales).to(torch.float32)
def _run_layers(
self,
hidden_states: torch.Tensor,
*,
position_ids: torch.Tensor | None = None,
visual_pos_masks: torch.Tensor | None = None,
deepstack_embeds: list[torch.Tensor] | None = None,
) -> torch.Tensor:
"""Run all 50 decoder blocks over an fp32 ``[batch, tokens, 5120]`` state.
With ``position_ids`` (``[3, seq]``) the Qwen3-VL interleaved mrope is used
instead of the plain 1D text rope; with ``visual_pos_masks`` +
``deepstack_embeds`` the three DeepStack features are added at the visual
positions of the first three decoder layers (Comfy ``Llama2_.forward``).
"""
hidden_states = hidden_states.to(self.dtype)
freqs_cis = None
if position_ids is not None:
from .qwen3vl_vision import mrope_freqs_cis
freqs_cis = mrope_freqs_cis(position_ids, device=hidden_states.device)
for layer_index, layer in enumerate(self.layers):
hidden_states = layer(hidden_states, freqs_cis)
if (
deepstack_embeds is not None
and visual_pos_masks is not None
and layer_index < len(deepstack_embeds)
):
mask = visual_pos_masks.to(hidden_states.device)
hidden_states[mask] = hidden_states[mask] + deepstack_embeds[layer_index].to(hidden_states)
return hidden_states
@torch.inference_mode()
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
"""Return unnormalized `[batch, tokens, 5120]` output after decoder layer 50."""
@ -185,12 +235,7 @@ class Qwen3VL32BTextEncoder(nn.Module):
raise ValueError(f"input_ids must have shape [batch, tokens], got {tuple(input_ids.shape)}")
if input_ids.numel() == 0:
raise ValueError("input_ids must contain at least one token")
token_rows = F.embedding(input_ids.to(self.embed_tokens.device), self.embed_tokens).to(torch.float32)
token_scales = F.embedding(input_ids.to(self.embed_scale.device), self.embed_scale)
hidden_states = (token_rows * token_scales).to(self.dtype)
for layer in self.layers:
hidden_states = layer(hidden_states)
return hidden_states
return self._run_layers(self._embed_rows(input_ids))
class Qwen3VLPromptConditioner:

View file

@ -0,0 +1,692 @@
"""Qwen3-VL vision conditioning for the direct H3 runtime (first/last keyframes).
Standalone port of the Comfy reference's Qwen3.5/Qwen3-VL visual stack and the
MiniMax H3 presentation mechanism, built only on ``torch``:
- ``Qwen35VisionModel`` (visual.pos_embed, visual.patch_embed, 27 visual.blocks
with 2D rotary attention, visual.merger) plus the three
``Qwen3VLDeepstackMerger`` (visual.deepstack_merger_list).
- ``process_qwen2vl_images`` image preprocessing (Qwen grid/resize/normalize).
- ``qwen2vl_mrope_position_ids`` (H3 mrope ids from ``embeds_info``),
``precompute_mrope_freqs_cis`` (interleaved Qwen3-VL text mrope), and
``token_tags_from_embeds_info`` (DiT per-token modality tags).
- Comfy ``Llama2_.forward`` DeepStack plumbing: per-decoder-layer additions at
visual positions for the first three decoder layers.
The visual tower is loaded from the same ``qwen3vl_32b_minimax_h3_nvfp4_awq``
safetensors the text encoder uses; its keys are all plain (bf16) tensors under
the ``visual.*`` prefix (verified at 1.1 GB, no quantized sub-tensors).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
import torch
from safetensors import safe_open
from torch import nn
from torch.nn import functional as F
# H3 presentation sentinels (see upstream_qwen3vl.py / upstream_text.py).
VISION_START = 151652
VISION_END = 151653
IMAGE_EMBED_TOKEN = 151655
# Qwen3-VL-32B visual geometry (QWEN3VL_VISION in upstream_qwen3vl.py).
VISION_HIDDEN = 1152
VISION_INTERMEDIATE = 4304
VISION_DEPTH = 27
VISION_HEADS = 16
VISION_HEAD_DIM = VISION_HIDDEN // VISION_HEADS # 72
VISION_PATCH = 16
VISION_TEMPORAL = 2
VISION_MERGE = 2
VISION_POSITION_EMBEDS = 2304
DEEPSTACK_VISUAL_INDEXES = (8, 16, 24)
# Qwen3-VL text mrope geometry (Qwen3VL_32BConfig in llama.py).
TEXT_ROPE_DIMS = (24, 20, 20)
TEXT_ROPE_THETA = 5_000_000.0
TEXT_HEAD_DIM = 128
# Qwen image preprocessing policy (process_qwen2vl_images, H3 mean/std 0.5).
QWEN_IMAGE_MEAN = (0.5, 0.5, 0.5)
QWEN_IMAGE_STD = (0.5, 0.5, 0.5)
QWEN_MIN_PIXELS = 3136
QWEN_MAX_PIXELS = 12845056
def process_image(
image: torch.Tensor,
*,
min_pixels: int = QWEN_MIN_PIXELS,
max_pixels: int = QWEN_MAX_PIXELS,
patch_size: int = VISION_PATCH,
temporal_patch_size: int = VISION_TEMPORAL,
merge_size: int = VISION_MERGE,
image_mean: tuple[float, ...] = QWEN_IMAGE_MEAN,
image_std: tuple[float, ...] = QWEN_IMAGE_STD,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Qwen image preprocessing (process_qwen2vl_images): a single
``[1, H, W, 3]`` float image (``[0, 1]``) -> (flatten_patches
``[grid_h*grid_w, C*tp*ps*ps]``, image_grid_thw ``[1, grid_h, grid_w]``).
"""
if image.ndim != 4 or image.shape[0] != 1:
raise ValueError("process_image expects a single [1, H, W, 3] tensor")
batch, height, width, _ = image.shape
device = image.device
images = image.permute(0, 3, 1, 2)
img = images[0]
factor = patch_size * merge_size
h_bar = round(height / factor) * factor
w_bar = round(width / factor) * factor
if h_bar * w_bar > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
h_bar = max(factor, math.floor(height / beta / factor) * factor)
w_bar = max(factor, math.floor(width / beta / factor) * factor)
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
h_bar = math.ceil(height * beta / factor) * factor
w_bar = math.ceil(width * beta / factor) * factor
img_resized = F.interpolate(img.unsqueeze(0), size=(h_bar, w_bar), mode="bilinear", align_corners=False).squeeze(0)
normalized = img_resized.clone()
for c in range(3):
normalized[c] = (img_resized[c] - image_mean[c]) / image_std[c]
grid_h = h_bar // patch_size
grid_w = w_bar // patch_size
grid_thw = torch.tensor([1, grid_h, grid_w], device=device, dtype=torch.long)
pixel_values = normalized
channel = pixel_values.shape[0]
grid_t = 1
pixel_values = pixel_values.unsqueeze(0).repeat(2, 1, 1, 1)
patches = pixel_values.reshape(
grid_t,
temporal_patch_size,
channel,
grid_h // merge_size,
merge_size,
patch_size,
grid_w // merge_size,
merge_size,
patch_size,
)
patches = patches.permute(0, 3, 6, 4, 7, 2, 1, 5, 8)
flatten = patches.reshape(grid_t * grid_h * grid_w, channel * temporal_patch_size * patch_size * patch_size)
return flatten, grid_thw
def mrope_position_ids(embeds_info: list[dict], seq_len: int, device) -> torch.Tensor | None:
"""(T, H, W) mrope ids for a sequence with spliced visual blocks (reference
``qwen2vl_mrope_position_ids``). ``embeds_info`` entries carry
``index``/``size`` spans and an ``extra`` dict with a ``grid`` tensor
``[1, grid_h, grid_w]``. Returns ``[3, seq_len]`` or ``None``."""
position_ids = None
offset = 0
for e in embeds_info:
if e.get("type") != "image":
continue
extra = e.get("extra", None)
grid = extra["grid"] if isinstance(extra, dict) else extra
start = e.get("index")
if position_ids is None:
position_ids = torch.zeros((3, seq_len), device=device)
position_ids[:, :start] = torch.arange(0, start, device=device)
end = e.get("size") + start
len_max = int(grid.max()) // 2
start_next = len_max + start
position_ids[:, end:] = torch.arange(start_next + offset, start_next + (seq_len - end) + offset, device=device)
position_ids[0, start:end] = start + offset
max_d = int(grid[0][1]) // 2
position_ids[1, start:end] = (
torch.arange(start + offset, start + max_d + offset, device=device)
.unsqueeze(1)
.repeat(1, math.ceil((end - start) / max_d))
.flatten(0)[: end - start]
)
max_d = int(grid[0][2]) // 2
position_ids[2, start:end] = (
torch.arange(start + offset, start + max_d + offset, device=device)
.unsqueeze(0)
.repeat(math.ceil((end - start) / max_d), 1)
.flatten(0)[: end - start]
)
offset += len_max - (end - start)
return position_ids
def token_tags(seq_len: int, embeds_info: list[dict], device) -> torch.Tensor:
"""DiT per-token AdaLN tags: 0 (video) inside a visual block including the
flanking sentinels, 1 (text) elsewhere (reference
``token_tags_from_embeds_info``)."""
tags = torch.ones(seq_len, dtype=torch.long, device=device)
for e in embeds_info:
if e.get("type") == "image":
start = max(0, e["index"] - 1)
stop = e["index"] + e["size"] + 1
tags[start:stop] = 0
return tags
def mrope_freqs_cis(position_ids: torch.Tensor, *, theta: float = TEXT_ROPE_THETA, head_dim: int = TEXT_HEAD_DIM, rope_dims: tuple[int, ...] = TEXT_ROPE_DIMS) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Interleaved Qwen3-VL text mrope (rope_dims=(24,20,20)).
``position_ids`` is ``[3, seq]``; returns ``(cos, sin, neg_sin)`` matching
the reference ``precompute_freqs_cis`` + ``apply_rope`` convention (shape
``[1, seq, head_dim]``; the ``neg_sin`` entry is ``-sin[..., half:]``).
"""
if position_ids.shape[0] < 1:
position_ids = position_ids.unsqueeze(0)
inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2, device=position_ids.device, dtype=torch.float32) / head_dim))
inv_freq_expanded = inv_freq[None, :, None].expand(position_ids.shape[0], -1, 1)
position_ids_expanded = position_ids[:, None, :].to(torch.float32)
freqs = (inv_freq_expanded @ position_ids_expanded).transpose(1, 2)
freqs_inter = freqs[0].clone()
for axis_idx, offset in ((1, 1), (2, 2)):
length = rope_dims[axis_idx - 1] * 3
idx = slice(offset, length, 3)
freqs_inter[..., idx] = freqs[axis_idx, ..., idx]
emb = torch.cat((freqs_inter, freqs_inter), dim=-1)
cos = emb.cos().unsqueeze(0)
sin = emb.sin().unsqueeze(0)
sine = sin[..., : sin.shape[-1] // 2]
negative_sine = -sin[..., sin.shape[-1] // 2 :]
return cos, sine, negative_sine
class _VisionPatchEmbed(nn.Module):
def __init__(self, weight: torch.Tensor, bias: torch.Tensor):
super().__init__()
self.register_buffer("weight", weight, persistent=False)
self.register_buffer("bias", bias, persistent=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
target = self.weight.dtype
x = x.view(-1, 3, VISION_TEMPORAL, VISION_PATCH, VISION_PATCH)
return F.conv3d(x.to(target), self.weight, self.bias, kernel_size=(VISION_TEMPORAL, VISION_PATCH, VISION_PATCH), stride=(VISION_TEMPORAL, VISION_PATCH, VISION_PATCH)).view(-1, self.weight.shape[0])
class _VisionMLP(nn.Module):
def __init__(self, fc1_w: torch.Tensor, fc1_b: torch.Tensor, fc2_w: torch.Tensor, fc2_b: torch.Tensor):
super().__init__()
self.register_buffer("fc1_weight", fc1_w, persistent=False)
self.register_buffer("fc1_bias", fc1_b, persistent=False)
self.register_buffer("fc2_weight", fc2_w, persistent=False)
self.register_buffer("fc2_bias", fc2_b, persistent=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return F.linear(F.gelu(F.linear(x, self.fc1_weight, self.fc1_bias), approximate="tanh"), self.fc2_weight, self.fc2_bias)
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
x1 = x[..., : x.shape[-1] // 2]
x2 = x[..., x.shape[-1] // 2 :]
return torch.cat((-x2, x1), dim=-1)
def _apply_rope_vision(q: torch.Tensor, k: torch.Tensor, freqs) -> tuple[torch.Tensor, torch.Tensor]:
"""Reference ``apply_rope`` (split-half) fed ``freqs=(cos, sin, neg_sin)``.
The vision rotary is built from the doubled-angle embedding so that
``sin[..., half:]`` already equals ``-sin[..., :half]``; the caller passes it
through unchanged, making this the rotate-half equivalent and keeping q/k at
their native dtype.
"""
cos, sin, neg_sin = freqs
q = (q * cos)
split = q.shape[-1] // 2
q[..., :split] += q[..., split:] * neg_sin
q[..., split:] += q[..., :split] * sin
k = (k * cos)
k[..., :split] += k[..., split:] * neg_sin
k[..., split:] += k[..., :split] * sin
return q, k
class _VisionAttention(nn.Module):
def __init__(self, qkv_w: torch.Tensor, qkv_b: torch.Tensor, proj_w: torch.Tensor, *, num_heads: int, head_dim: int):
super().__init__()
self.num_heads = num_heads
self.head_dim = head_dim
self.register_buffer("qkv_weight", qkv_w, persistent=False)
self.register_buffer("qkv_bias", qkv_b, persistent=False)
self.proj_weight = proj_w # no bias (Qwen3.5 vision proj has none)
def forward(self, x: torch.Tensor, cu_seqlens: torch.Tensor, position_embeddings: torch.Tensor) -> torch.Tensor:
seq_length = x.shape[0]
query_states, key_states, value_states = (
F.linear(x, self.qkv_weight, self.qkv_bias).reshape(seq_length, 3, self.num_heads, self.head_dim).permute(1, 0, 2, 3).unbind(0)
)
query_states, key_states = _apply_rope_vision(query_states, key_states, position_embeddings)
lengths = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
attn_outputs = []
for q, k, v in zip(
torch.split(query_states, lengths, dim=0),
torch.split(key_states, lengths, dim=0),
torch.split(value_states, lengths, dim=0),
):
attn_outputs.append(F.scaled_dot_product_attention(q.transpose(0, 1).unsqueeze(0), k.transpose(0, 1).unsqueeze(0), v.transpose(0, 1).unsqueeze(0)))
attn_output = torch.cat(attn_outputs, dim=1)
attn_output = attn_output.reshape(seq_length, -1)
return F.linear(attn_output, self.proj_weight)
class _VisionBlock(nn.Module):
def __init__(self, norm1_w: torch.Tensor, norm1_b: torch.Tensor, attn: _VisionAttention, norm2_w: torch.Tensor, norm2_b: torch.Tensor, mlp: _VisionMLP):
super().__init__()
self.attn = attn
self.mlp = mlp
self.register_buffer("norm1_weight", norm1_w, persistent=False)
self.register_buffer("norm1_bias", norm1_b, persistent=False)
self.register_buffer("norm2_weight", norm2_w, persistent=False)
self.register_buffer("norm2_bias", norm2_b, persistent=False)
def forward(self, x: torch.Tensor, cu_seqlens: torch.Tensor, position_embeddings: torch.Tensor) -> torch.Tensor:
x = x + self.attn(F.layer_norm(x, (x.shape[-1],), weight=self.norm1_weight, bias=self.norm1_bias, eps=1e-6), cu_seqlens=cu_seqlens, position_embeddings=position_embeddings)
return x + self.mlp(F.layer_norm(x, (x.shape[-1],), weight=self.norm2_weight, bias=self.norm2_bias, eps=1e-6))
class _VisionPatchMerger(nn.Module):
"""Spatial 2x2 merge + projection to text width (main or deepstack)."""
def __init__(self, norm_w: torch.Tensor, norm_b: torch.Tensor, fc1_w: torch.Tensor, fc1_b: torch.Tensor, fc2_w: torch.Tensor, fc2_b: torch.Tensor, *, merge_size: int, out_hidden_size: int):
super().__init__()
self.merge_dim = VISION_HIDDEN * (merge_size ** 2)
self.register_buffer("norm_weight", norm_w, persistent=False)
self.register_buffer("norm_bias", norm_b, persistent=False)
self.register_buffer("fc1_weight", fc1_w, persistent=False)
self.register_buffer("fc1_bias", fc1_b, persistent=False)
self.register_buffer("fc2_weight", fc2_w, persistent=False)
self.register_buffer("fc2_bias", fc2_b, persistent=False)
self.out_hidden_size = out_hidden_size
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: [seq, hidden] (already spatially merged upstream).
x = F.layer_norm(x, (VISION_HIDDEN,), weight=self.norm_weight, bias=self.norm_bias, eps=1e-6)
x = x.view(-1, self.merge_dim)
return F.linear(F.gelu(F.linear(x, self.fc1_weight, self.fc1_bias), approximate="tanh"), self.fc2_weight, self.fc2_bias)
def resize_keyframe(image: torch.Tensor, width: int, height: int, *, crop: str = "disabled") -> torch.Tensor:
"""Resize a ``[1, H, W, 3]`` float ``[0, 1]`` keyframe to the ``width x height``
canvas the way the Comfy reference does (``common_upscale(..., "lanczos", crop)``):
- ``crop="disabled"`` (first frame) stretches to the canvas.
- ``crop="center"`` (last frame) aspect-covers then center-crops.
Returns ``[1, height, width, 3]`` float ``[0, 1]``.
"""
if image.ndim != 4 or image.shape[0] != 1:
raise ValueError("resize_keyframe expects a single [1, H, W, 3] image")
samples = image[:, :, :, :3].movedim(-1, 1) # [1, 3, H, W]
if crop == "center":
old_h, old_w = samples.shape[-2], samples.shape[-1]
old_aspect = old_w / old_h
new_aspect = width / height
x = 0
y = 0
if old_aspect > new_aspect:
x = round((old_w - old_w * (new_aspect / old_aspect)) / 2)
elif old_aspect < new_aspect:
y = round((old_h - old_h * (old_aspect / new_aspect)) / 2)
samples = samples.narrow(-2, y, old_h - 2 * y).narrow(-1, x, old_w - 2 * x)
samples = F.interpolate(samples, size=(height, width), mode="lanczos")
return samples.clamp(0.0, 1.0).movedim(1, -1) # [1, H, W, 3]
def _text_run_ids(prompt: str) -> list[int]:
"""Token ids for a raw text run (``add_special_tokens=False``, no template)."""
from .conditioning import H3PromptTokenizer
tokenizer_dir = Path(__file__).with_name("qwen25_tokenizer")
if not tokenizer_dir.exists():
raise FileNotFoundError(f"Qwen tokenizer directory missing: {tokenizer_dir}")
return H3PromptTokenizer(tokenizer_dir)(prompt or " ")
@dataclass
class Fl2vaPresentation:
"""Expanded first/last keyframe prompt for the H3 DiT.
Carries the Qwen layer-50 text states (with spliced vision pads), the per-token
DiT modality tags, the mrope ids, and the DiT-level keyframe anchors used to
build the cond rows.
"""
input_ids: torch.Tensor
text_states: torch.Tensor
text_token_tags: torch.Tensor
embeds_info: list[dict]
keyframes: list[dict]
frame_count: int
def build_fl2va_presentation(
prompt: str,
first_frame: torch.Tensor | None,
last_frame: torch.Tensor | None,
*,
width: int,
height: int,
frame_count: int,
tokenizer,
vision: Qwen3VL32BVision,
text_encoder: "Qwen3VL32BTextEncoder",
device,
) -> Fl2vaPresentation:
"""Build the fl2va presentation and run the Qwen text conditioner over it.
``first_frame`` / ``last_frame`` are ``[1, H, W, 3]`` ``[0, 1]`` images (already
aligned to the target canvas by the caller when needed). The presentation
sequence is ``[Picture1 <img> Picture2? <img> prompt]``: raw text ids with the
``VISION_START`` sentinel, a vision pad run, and ``VISION_END`` per keyframe,
followed by the raw prompt ids (no chat template).
"""
images: list[torch.Tensor] = []
keyframes: list[dict] = []
if first_frame is not None:
img = resize_keyframe(first_frame, width, height, crop="disabled")
images.append(img)
keyframes.append({"resolved_frame_index": 0, "image": img})
if last_frame is not None:
img = resize_keyframe(last_frame, width, height, crop="center")
images.append(img)
keyframes.append({"resolved_frame_index": frame_count - 1, "image": img})
# Build the entry list: (token_id/"text") runs and image placeholders.
entries: list = []
for i in range(len(images)):
entries.extend((tid, "text") for tid in _text_run_ids(f"<Picture {i + 1}: "))
entries.append((VISION_START, "text"))
entries.append((i, "image"))
entries.append((VISION_END, "text"))
entries.extend((tid, "text") for tid in _text_run_ids(prompt))
if not any(kind == "text" for _, kind in entries):
entries = [(151643, "text")]
# Expand images through the vision tower up front (needs the vision tower); the
# merged output becomes the pad run that the token sequence points at.
vision_outputs: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[torch.Tensor], int]] = []
for i in range(len(images)):
flatten, grid = process_image(images[i].to(device))
merged, deepstack = vision(flatten, grid.to(device))
vision_outputs.append((flatten, grid, merged, deepstack, merged.shape[0]))
# Expand into a flat token list + embeds_info.
token_ids: list[int] = []
embeds_info: list[dict] = []
vision_index = 0
for tok, kind in entries:
if kind == "image":
flatten, grid, merged, deepstack, size = vision_outputs[vision_index]
start = len(token_ids)
token_ids.extend([0] * size) # placeholder ids, overwritten by the merged rows
embeds_info.append({"type": "image", "index": start, "size": size, "extra": {"grid": grid, "deepstack": deepstack, "merged": merged}})
vision_index += 1
else:
token_ids.append(tok)
seq_len = len(token_ids)
input_ids = torch.tensor(token_ids, device=device)
# Build the Qwen hidden-state input: scaled token embeds, with the merged vision
# rows spliced over the pad positions.
base_embeds = text_encoder._embed_rows(input_ids) # [1, seq, 5120] fp32
visual_pos_masks = torch.zeros((1, seq_len), dtype=torch.bool, device=device)
for e in embeds_info:
merged = e["extra"]["merged"]
base_embeds[0, e["index"]: e["index"] + e["size"], :] = merged.to(base_embeds.dtype)
visual_pos_masks[0, e["index"]: e["index"] + e["size"]] = True
# DeepStack features: one concatenated tensor per vision-layer index, spanning all
# spliced blocks in sequence order.
merged_deepstack_per_index: dict = {}
for e in embeds_info:
for i, ds in enumerate(e["extra"]["deepstack"]):
merged_deepstack_per_index.setdefault(i, []).append(ds)
deepstack_embeds = [torch.cat(v, dim=0) for i, v in sorted(merged_deepstack_per_index.items())]
position_ids = mrope_position_ids(embeds_info, seq_len, device)
text_states = text_encoder._run_layers(
base_embeds.float(),
position_ids=position_ids,
visual_pos_masks=visual_pos_masks,
deepstack_embeds=deepstack_embeds,
)
tags = token_tags(seq_len, embeds_info, device)
return Fl2vaPresentation(
input_ids=input_ids,
text_states=text_states,
text_token_tags=tags,
embeds_info=embeds_info,
keyframes=keyframes,
frame_count=frame_count,
)
class Qwen3VL32BVision(nn.Module):
"""H3's Qwen3-VL-32B visual tower (+ DeepStack mergers), from the text-encoder safetensors."""
def __init__(self, checkpoint_path: str | Path, *, device, dtype: torch.dtype):
super().__init__()
self.device = device
self.dtype = dtype
self.spatial_merge_size = VISION_MERGE
self.spatial_merge_unit = VISION_MERGE * VISION_MERGE
self.patch_size = VISION_PATCH
self.hidden_size = VISION_HIDDEN
self.num_heads = VISION_HEADS
self.num_position_embeddings = VISION_POSITION_EMBEDS
self.num_grid_per_side = int(self.num_position_embeddings ** 0.5)
self.depth = VISION_DEPTH
self.deepstack_visual_indexes = list(DEEPSTACK_VISUAL_INDEXES)
self.out_hidden_size = 5120
checkpoint_path = str(checkpoint_path)
required = {
"visual.pos_embed.weight",
"visual.patch_embed.proj.weight",
"visual.patch_embed.proj.bias",
"visual.merger.norm.weight",
"visual.merger.norm.bias",
"visual.merger.linear_fc1.weight",
"visual.merger.linear_fc1.bias",
"visual.merger.linear_fc2.weight",
"visual.merger.linear_fc2.bias",
}
for i in range(self.depth):
required.update({
f"visual.blocks.{i}.norm1.weight",
f"visual.blocks.{i}.norm1.bias",
f"visual.blocks.{i}.attn.qkv.weight",
f"visual.blocks.{i}.attn.qkv.bias",
f"visual.blocks.{i}.attn.proj.weight",
f"visual.blocks.{i}.norm2.weight",
f"visual.blocks.{i}.norm2.bias",
f"visual.blocks.{i}.mlp.linear_fc1.weight",
f"visual.blocks.{i}.mlp.linear_fc1.bias",
f"visual.blocks.{i}.mlp.linear_fc2.weight",
f"visual.blocks.{i}.mlp.linear_fc2.bias",
})
for i in range(len(self.deepstack_visual_indexes)):
required.update({
f"visual.deepstack_merger_list.{i}.norm.weight",
f"visual.deepstack_merger_list.{i}.norm.bias",
f"visual.deepstack_merger_list.{i}.linear_fc1.weight",
f"visual.deepstack_merger_list.{i}.linear_fc1.bias",
f"visual.deepstack_merger_list.{i}.linear_fc2.weight",
f"visual.deepstack_merger_list.{i}.linear_fc2.bias",
})
with safe_open(checkpoint_path, framework="pt", device=device) as checkpoint:
names = set(checkpoint.keys())
missing = sorted(required - names)
if missing:
raise ValueError("Not a MiniMax H3 Qwen3-VL vision checkpoint; missing: " + ", ".join(missing[:12]))
self._init_modules(
device,
dtype,
lambda name: checkpoint.get_tensor(name).to(device=device, dtype=dtype),
)
def _init_modules(self, device, dtype, get) -> None:
self.register_buffer("pos_embed", get("visual.pos_embed.weight"), persistent=False)
self.patch_embed = _VisionPatchEmbed(get("visual.patch_embed.proj.weight"), get("visual.patch_embed.proj.bias"))
self.merger = _VisionPatchMerger(
get("visual.merger.norm.weight"), get("visual.merger.norm.bias"),
get("visual.merger.linear_fc1.weight"), get("visual.merger.linear_fc1.bias"),
get("visual.merger.linear_fc2.weight"), get("visual.merger.linear_fc2.bias"),
merge_size=self.spatial_merge_size, out_hidden_size=self.out_hidden_size,
)
self.deepstack_merger_list = nn.ModuleList([
_VisionPatchMerger(
get(f"visual.deepstack_merger_list.{i}.norm.weight"), get(f"visual.deepstack_merger_list.{i}.norm.bias"),
get(f"visual.deepstack_merger_list.{i}.linear_fc1.weight"), get(f"visual.deepstack_merger_list.{i}.linear_fc1.bias"),
get(f"visual.deepstack_merger_list.{i}.linear_fc2.weight"), get(f"visual.deepstack_merger_list.{i}.linear_fc2.bias"),
merge_size=self.spatial_merge_size, out_hidden_size=self.out_hidden_size,
)
for i in range(len(self.deepstack_visual_indexes))
])
self.blocks = nn.ModuleList([
_VisionBlock(
get(f"visual.blocks.{i}.norm1.weight"), get(f"visual.blocks.{i}.norm1.bias"),
_VisionAttention(
get(f"visual.blocks.{i}.attn.qkv.weight"), get(f"visual.blocks.{i}.attn.qkv.bias"),
get(f"visual.blocks.{i}.attn.proj.weight"),
num_heads=self.num_heads, head_dim=VISION_HEAD_DIM,
),
get(f"visual.blocks.{i}.norm2.weight"), get(f"visual.blocks.{i}.norm2.bias"),
_VisionMLP(
get(f"visual.blocks.{i}.mlp.linear_fc1.weight"), get(f"visual.blocks.{i}.mlp.linear_fc1.bias"),
get(f"visual.blocks.{i}.mlp.linear_fc2.weight"), get(f"visual.blocks.{i}.mlp.linear_fc2.bias"),
),
)
for i in range(self.depth)
])
self.rotary_pos_emb = _VisionRotary(VISION_HIDDEN // self.num_heads // 2, device, dtype)
def rot_pos_emb(self, grid_thw: torch.Tensor) -> torch.Tensor:
"""Reference ``rot_pos_emb``: (row, col) coords -> 2D rotary angles per token."""
merge_size = self.spatial_merge_size
grid_thw_list = grid_thw.tolist()
max_hw = max(max(h, w) for _, h, w in grid_thw_list)
freq_table = self.rotary_pos_emb(max_hw).to(grid_thw.device)
device = freq_table.device
total_tokens = sum(int(t * h * w) for t, h, w in grid_thw_list)
pos_ids = torch.empty((total_tokens, 2), dtype=torch.long, device=device)
offset = 0
for num_frames, height, width in grid_thw_list:
num_frames, height, width = int(num_frames), int(height), int(width)
merged_h, merged_w = height // merge_size, width // merge_size
block_rows = torch.arange(merged_h, device=device)
block_cols = torch.arange(merged_w, device=device)
intra_row = torch.arange(merge_size, device=device)
intra_col = torch.arange(merge_size, device=device)
row_idx = (block_rows[:, None, None, None] * merge_size + intra_row[None, None, :, None]).expand(merged_h, merged_w, merge_size, merge_size).reshape(-1)
col_idx = (block_cols[None, :, None, None] * merge_size + intra_col[None, None, :, None].expand(merged_h, merged_w, merge_size, merge_size).reshape(-1))
coords = torch.stack((row_idx, col_idx), dim=-1)
if num_frames > 1:
coords = coords.repeat(num_frames, 1)
num_tokens = coords.shape[0]
pos_ids[offset:offset + num_tokens] = coords
offset += num_tokens
return freq_table[pos_ids].flatten(1)
def fast_pos_embed_interpolate(self, grid_thw: torch.Tensor) -> torch.Tensor:
"""Reference 4-tap bilinear interpolation of the learned 48x48 grid."""
grid_ts = [int(row[0]) for row in grid_thw.tolist()]
grid_hs = [int(row[1]) for row in grid_thw.tolist()]
grid_ws = [int(row[2]) for row in grid_thw.tolist()]
device = self.pos_embed.device
idx_list: list[list] = [[] for _ in range(4)]
weight_list: list[list] = [[] for _ in range(4)]
for t, h, w in zip(grid_ts, grid_hs, grid_ws):
h, w = int(h), int(w)
h_idxs = torch.linspace(0, self.num_grid_per_side - 1, h, device=device)
w_idxs = torch.linspace(0, self.num_grid_per_side - 1, w, device=device)
h_floor = h_idxs.int()
w_floor = w_idxs.int()
h_ceil = (h_idxs + 1).int().clamp(max=self.num_grid_per_side - 1)
w_ceil = (w_idxs + 1).int().clamp(max=self.num_grid_per_side - 1)
dh = h_idxs - h_floor
dw = w_idxs - w_floor
base_h = h_floor * self.num_grid_per_side
base_h_ceil = h_ceil * self.num_grid_per_side
indices = [
(base_h[None].T + w_floor[None]).flatten(),
(base_h[None].T + w_ceil[None]).flatten(),
(base_h_ceil[None].T + w_floor[None]).flatten(),
(base_h_ceil[None].T + w_ceil[None]).flatten(),
]
weights = [
((1 - dh)[None].T * (1 - dw)[None]).flatten(),
((1 - dh)[None].T * dw[None]).flatten(),
(dh[None].T * (1 - dw)[None]).flatten(),
(dh[None].T * dw[None]).flatten(),
]
for j in range(4):
idx_list[j].extend(indices[j].tolist())
weight_list[j].extend(weights[j].tolist())
idx_tensor = torch.tensor(idx_list, dtype=torch.long, device=device)
weight_tensor = torch.tensor(weight_list, dtype=self.pos_embed.dtype, device=device)
pos_embeds = self.pos_embed[idx_tensor] * weight_tensor[:, :, None]
patch_pos_embeds = pos_embeds[0] + pos_embeds[1] + pos_embeds[2] + pos_embeds[3]
patch_pos_embeds = patch_pos_embeds.split([h * w for h, w in zip(grid_hs, grid_ws)])
patch_pos_embeds_permute = []
merge_size = self.spatial_merge_size
for pos_embed, t, h, w in zip(patch_pos_embeds, grid_ts, grid_hs, grid_ws):
pos_embed = pos_embed.repeat(t, 1)
pos_embed = pos_embed.view(t, h // merge_size, merge_size, w // merge_size, merge_size, -1).permute(0, 1, 3, 2, 4, 5).flatten(0, 4)
patch_pos_embeds_permute.append(pos_embed)
return torch.cat(patch_pos_embeds_permute)
@staticmethod
def _merge_tokens(x: torch.Tensor, grid_thw: torch.Tensor) -> torch.Tensor:
"""Flatten 2x2 spatial-merge blocks (the reference's pre-merge flatten)."""
t, h, w = (int(v) for v in grid_thw.tolist()[0])
merge = 2
return x.view(t, h // merge, merge, w // merge, merge, -1).permute(0, 1, 3, 2, 4, 5).reshape(-1, -1)
def forward(self, flatten_patches: torch.Tensor, grid_thw: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
"""Run the visual tower -> (merged, deepstack)."""
x = self.patch_embed(flatten_patches.to(self.dtype).to(self.device))
x = x + self.fast_pos_embed_interpolate(grid_thw).to(x.device)
x = x.reshape(x.shape[0], -1)
rotary = self.rot_pos_emb(grid_thw.to(x.device)).to(x.device).reshape(x.shape[0], -1)
emb = torch.cat((rotary, rotary), dim=-1)
cos = emb.cos().unsqueeze(-2)
sin = emb.sin().unsqueeze(-2)
sin_split = sin.shape[-1] // 2
position_embeddings = (cos, sin[..., :sin_split], -sin[..., sin_split:])
cu_seqlens = F.pad(
torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).cumsum(0, dtype=torch.int32),
(1, 0), value=0,
)
deepstack_features = []
for layer_num, block in enumerate(self.blocks):
x = block(x, cu_seqlens=cu_seqlens, position_embeddings=position_embeddings)
if layer_num in self.deepstack_visual_indexes:
deepstack_features.append(self.deepstack_merger_list[self.deepstack_visual_indexes.index(layer_num)](self._merge_tokens(x, grid_thw)))
return self.merger(self._merge_tokens(x, grid_thw)), deepstack_features
class _VisionRotary(nn.Module):
def __init__(self, dim: int, device, dtype):
super().__init__()
inv_freq = 1.0 / (10000.0 ** (torch.arange(0, dim, 2, dtype=torch.float, device=device) / dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
def forward(self, seqlen: int) -> torch.Tensor:
seq = torch.arange(seqlen, device=self.inv_freq.device, dtype=self.inv_freq.dtype)
return torch.outer(seq, self.inv_freq)

View file

@ -17,10 +17,12 @@ from .checkpoint import H3Checkpoint
from .denoiser import H3PackedDenoiser
from .packing import H3PromptPacker
from .qwen3vl_text import Qwen3VLPromptConditioner
from .qwen3vl_vision import build_fl2va_presentation, Qwen3VL32BVision
from .sampler import sample_video_res_multistep
from .t2v import random_av_latents
from .token_refiner import H3TokenRefiner
from .vae_decoder import MiniMaxH3VideoVAE, dtype_from_name
from .vae_encoder import MiniMaxH3VideoVAEEncoder
@dataclass(frozen=True)
@ -85,6 +87,14 @@ class H3HotRuntime:
"audio_vae_loaded",
lambda: MiniMaxH3AudioVAE.from_safetensors(config.audio_vae_path, device=config.device).eval(),
)
self.vae_encoder = self._timed_load(
"vae_encoder_loaded",
lambda: MiniMaxH3VideoVAEEncoder.from_safetensors(config.video_vae_path, device=config.device).eval(),
)
self.vision_tower = self._timed_load(
"vision_tower_loaded",
lambda: Qwen3VL32BVision(config.text_encoder_path, device=config.device, dtype=torch.bfloat16),
)
def _timed_load(self, stage: str, fn):
_sync()
@ -134,6 +144,44 @@ class H3HotRuntime:
block.attention_backend = _refiner_attention_backend(attention)
self.attention = attention
def _build_fl2va(self, prompt: str, first_frame: torch.Tensor | None, last_frame: torch.Tensor | None, width: int, height: int, aligned_frames: int):
from .qwen3vl_vision import build_fl2va_presentation, resize_keyframe
device = self.config.device
first = self._image_to_uint8_nhwc(first_frame.to(device)).float() / 255.0 if first_frame is not None else None
last = self._image_to_uint8_nhwc(last_frame.to(device)).float() / 255.0 if last_frame is not None else None
presentation = build_fl2va_presentation(
prompt,
first,
last,
width=width,
height=height,
frame_count=aligned_frames,
tokenizer=self.conditioner.tokenizer,
vision=self.vision_tower,
text_encoder=self.conditioner.encoder,
device=device,
)
# resize each keyframe to the canvas and VAE-encode it (the DiT cond latent).
cond_latents = []
for kf in presentation.keyframes:
resized = resize_keyframe(kf["image"].to(device), width, height, crop="disabled" if kf["resolved_frame_index"] == 0 else "center")
pix = resized.movedim(-1, 1).to(device, dtype=torch.float32)
cond_latents.append(self.vae_encoder.encode(pix))
return presentation, cond_latents, aligned_frames
def _image_to_uint8_nhwc(self, img: torch.Tensor) -> torch.Tensor:
"""Normalize a [1,3,H,W] image in either [-1,1] or [0,255] to [1,H,W,3] uint8."""
x = img.float()
if x.numel() == 0:
return x
mx = x.max()
if mx > 2.0:
x = x / 255.0 # already 0..255
else:
x = (x.clamp(-1, 1) + 1) * 0.5 # -1..1 -> 0..1
return (x.movedim(1, -1).clamp(0, 1) * 255).to(torch.uint8)
@torch.inference_mode()
def generate(
self,
@ -146,6 +194,8 @@ class H3HotRuntime:
steps: int,
seed: int,
attention: str | None = None,
first_frame: torch.Tensor | None = None,
last_frame: torch.Tensor | None = None,
mux_audio: bool = True,
ffmpeg_loglevel: str = "error",
save_latent: str | Path | None = None,
@ -175,7 +225,24 @@ class H3HotRuntime:
"latents_initialized",
lambda: random_av_latents(width, height, frames, seed, device=self.config.device),
)
text = timed("text_conditioned", lambda: self.refiner(self.conditioner(prompt)))
use_fl2va = first_frame is not None or last_frame is not None
if use_fl2va:
# Build the fl2va Qwen presentation, encode the keyframes to cond latents,
# and refine the text span (vision-aware).
presentation, cond_latents, frame_count = timed(
"fl2va_conditioned",
lambda: self._build_fl2va(prompt, first_frame, last_frame, width, height, aligned_frames),
)
text = timed("text_conditioned", lambda: self.refiner(presentation.text_states))
pack_kwargs = {
"text_token_tags": presentation.text_token_tags,
"cond_latents": cond_latents,
"frame_count": frame_count,
"seed": seed,
}
else:
text = timed("text_conditioned", lambda: self.refiner(self.conditioner(prompt)))
pack_kwargs = {}
sampled = timed(
"sampled",
lambda: sample_video_res_multistep(
@ -185,6 +252,7 @@ class H3HotRuntime:
video,
audio,
steps=steps,
seed=seed,
return_audio=mux_audio,
cache_mode=cache_mode,
cache_threshold=cache_threshold,
@ -192,6 +260,7 @@ class H3HotRuntime:
cache_end_percent=cache_end_percent,
cache_subsample_factor=cache_subsample_factor,
cache_stats=cache_stats,
**pack_kwargs,
),
)
if mux_audio:

View file

@ -77,6 +77,10 @@ def sample_video_res_multistep(
model_timesteps: list[torch.Tensor] | tuple[torch.Tensor, ...] | None = None,
return_audio: bool = False,
progress: bool = False,
seed: int = 0,
text_token_tags: torch.Tensor | None = None,
cond_latents: list[torch.Tensor] | None = None,
frame_count: int | None = None,
cache_mode: str | None = None,
cache_threshold: float = 0.0,
cache_start_percent: float = 0.0,
@ -136,7 +140,17 @@ def sample_video_res_multistep(
carry = sigma_audio / sigma
native_audio = audio_carried.to(torch.bfloat16) * carry
step_timesteps = None if model_timesteps is None else model_timesteps[previous_index]
hidden, times, segments, positions, video_segment, audio_segment = packer(text, video, native_audio, _model_sigma(sigma), step_timesteps)
hidden, times, segments, positions, video_segment, audio_segment = packer(
text,
video,
native_audio,
_model_sigma(sigma),
step_timesteps,
text_token_tags=text_token_tags,
cond_latents=cond_latents,
frame_count=frame_count,
seed=seed,
)
raw_video, raw_audio = model(hidden, times, positions, segments, video_segment, audio_segment)
raw_video = raw_video.to(torch.bfloat16).float()
raw_audio = raw_audio.to(torch.bfloat16)

View file

@ -0,0 +1,294 @@
"""Direct, encoder-only MiniMax H3 video VAE implementation.
Mirrors the encoder half of ``upstream_vae.py`` so keyframe/reference images can
be encoded without ComfyUI. The encoder runs in FP32 and the latent moments are
upcast to FP32 before mean/std normalization (the reference contract).
Causal-conv semantics: spatial padding is reflect; temporal padding is causal
(front-only zeros) with a stride grid that starts at the first input frame
(Comfy autopad "same" / ``causal``). For a single input frame the temporal
taps of the kernel are truncated (Comfy ``autopad="causal_zero"``) so the frame
is not convolved against zero frames.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
import torch
from safetensors import safe_open
from torch import nn
from torch.nn import functional as F
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
LATENTS_MEAN = (0.858090341091156, -0.9606591463088989, 1.0661640167236328, -0.5090325474739075, -0.2727581858634949, -1.3675414323806763, -0.2553254961967468, -0.26907554268836975, -0.5376840829849243, -0.0464097298681736, 0.6657370328903198, 0.19690127670764923, -0.5460608005523682, -0.4035342037677765, -0.23683024942874908, 0.25928452610969543, -0.30133944749832153, 0.211341992020607, -1.1206848621368408, 0.3581933379173279, -0.04225143790245056, 0.2604829967021942, 0.22864092886447906, 0.7056031823158264)
LATENTS_STD = (1.2223774194717407, 1.2767263650894165, 1.68317747116088865, 1.7549455165863037, 1.5636216402053833, 2.194143533706665, 0.96531379222869875, 1.05698859691619875, 0.841948926448822, 0.7729952931404114, 1.8955937623977661, 0.946841835975647, 0.7996809482574463, 0.44988900423049925, 0.7197399735450745, 0.69362932443618775, 2.961095094680786, 2.7694199085235595, 3.0496184825897215, 2.1088054180145265, 3.276226282119751, 3.1627357006073, 2.28168129920959475, 2.6127843856811525)
def _causal_front_padding(t_in: int, kernel: int, stride: int) -> int:
"""Front-only zero padding matching the reference causal 3D conv.
``padding = kernel - 1 - (t_out - 1) * stride`` with ``t_out = ceil(t_in /
stride)`` (the reference computes the output length from the unpadded
input). The result is non-negative: with a stride-s grid and a (2k+1)
kernel, ``ceil(t/stride) >= 1 + (t-1) // stride`` for all t, so
``(t_in - 1) % stride * stride >= (kernel - 1) % (2 * stride)``.
"""
t_out = math.ceil(t_in / stride)
return max(0, kernel - 1 - (t_out - 1) * stride)
class _CausalConv3d(nn.Module):
"""3D conv: reflect spatial padding, causal (front-zero) temporal padding."""
def __init__(self, in_channels: int, out_channels: int, kernel_size: int, stride: int | tuple[int, int, int] = 1, spatial_padding: int = 0):
super().__init__()
self.kernel_size = kernel_size
self.stride = stride if isinstance(stride, tuple) else (stride, stride, stride)
self.spatial_padding = spatial_padding
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size, stride=self.stride, padding=(0, spatial_padding, spatial_padding))
def forward(self, x: torch.Tensor) -> torch.Tensor:
t = x.shape[2]
if t == 1:
# A single input frame never convolves against zero frames: the
# temporal taps are truncated (Comfy autopad="causal_zero").
if self.spatial_padding > 0:
half = (self.kernel_size - 1) // 2
kernel = self.conv.weight[:, :, half:half + 2 * self.spatial_padding + 1]
return F.conv3d(x, kernel, self.conv.bias, (1, self.stride[1], self.stride[2]), (0, self.spatial_padding, self.spatial_padding))
return self.conv(x)
front = _causal_front_padding(t, self.kernel_size, self.stride[0])
if front > 0:
x = F.pad(x, (0, 0, 0, 0, front, 0))
return self.conv(x)
class TemporalIsolatedGroupNorm(nn.GroupNorm):
"""GroupNorm with statistics computed per frame (time merged into batch)."""
def forward(self, x: torch.Tensor) -> torch.Tensor:
if x.dim() != 5:
return super().forward(x)
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).contiguous().view(b * t, c, 1, h, w)
x = super().forward(x)
return x.view(b, t, c, h, w).permute(0, 2, 1, 3, 4).contiguous()
def group_norm_3d(num_channels: int) -> TemporalIsolatedGroupNorm:
return TemporalIsolatedGroupNorm(num_groups=32, num_channels=num_channels, eps=1e-6, affine=True)
class Downsample3D(nn.Module):
def __init__(self, in_channels: int, out_channels: int, time_stride: int = 1, space_stride: int = 2):
super().__init__()
self.space_stride = space_stride
self.conv = _CausalConv3d(in_channels, out_channels, kernel_size=3, stride=(time_stride, space_stride, space_stride), spatial_padding=0)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.space_stride == 2:
x = F.pad(x, (0, 1, 0, 1, 0, 0), mode="reflect")
return self.conv(x)
class ResnetBlock3D(nn.Module):
def __init__(self, in_channels: int, out_channels: int | None = None):
super().__init__()
self.in_channels = in_channels
self.out_channels = in_channels if out_channels is None else out_channels
self.norm1 = group_norm_3d(in_channels)
self.norm2 = group_norm_3d(self.out_channels)
self.conv1 = _CausalConv3d(in_channels, self.out_channels, kernel_size=3, spatial_padding=1)
self.conv2 = _CausalConv3d(self.out_channels, self.out_channels, kernel_size=3, spatial_padding=1)
if self.in_channels != self.out_channels:
self.nin_shortcut = _CausalConv3d(self.in_channels, self.out_channels, kernel_size=1, spatial_padding=0)
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.conv1(F.silu(self.norm1(x), inplace=True))
h = self.conv2(F.silu(self.norm2(h), inplace=True))
if self.in_channels != self.out_channels:
x = self.nin_shortcut(x)
return h.add_(x)
class EncoderFCN3D(nn.Module):
def __init__(self, ch: int, ch_mult: tuple[int, ...], space_down: tuple[int, ...], time_down: tuple[int, ...], num_res_blocks: int, in_channels: int, z_channels: int, double_z: bool = True):
super().__init__()
self.num_levels = len(ch_mult)
self.num_res_blocks = [num_res_blocks] * self.num_levels
block_mid = [ch * ch_mult[i] for i in range(self.num_levels)]
block_in = [block_mid[0]] + block_mid[:-1]
self.conv_in = _CausalConv3d(in_channels, block_in[0], kernel_size=3, spatial_padding=1)
self.down = nn.ModuleList()
for i_level in range(self.num_levels):
down = nn.Module()
down.block = nn.ModuleList(
ResnetBlock3D(block_in[i_level] if i == 0 else block_mid[i_level], block_mid[i_level])
for i in range(self.num_res_blocks[i_level])
)
if space_down[i_level] * time_down[i_level] > 1:
down.downsample = Downsample3D(block_mid[i_level], block_mid[i_level], time_stride=time_down[i_level], space_stride=space_down[i_level])
self.down.append(down)
self.norm_out = group_norm_3d(block_mid[-1])
self.conv_out = _CausalConv3d(block_mid[-1], 2 * z_channels if double_z else z_channels, kernel_size=3, spatial_padding=1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.conv_in(x)
for i_level in range(self.num_levels):
for i_block in range(self.num_res_blocks[i_level]):
h = self.down[i_level].block[i_block](h)
if hasattr(self.down[i_level], "downsample"):
h = self.down[i_level].downsample(h)
h = F.silu(self.norm_out(h), inplace=True)
return self.conv_out(h)
class MiniMaxH3VideoVAEEncoder(nn.Module):
"""Encoder-only H3 VAE. ``encode`` matches the public contract of upstream_vae.py."""
def __init__(self, *, device: torch.device | str | None = None, tiling: bool = True):
super().__init__()
self.vae_ratio, self.vae_ratio_t = 16, 4
self.clip_length, self.token_drop = 17, 3
self.tiling, self.tile_size, self.tile_overlap_min = tiling, 256, 64
self.encoder = EncoderFCN3D(ch=128, ch_mult=(1, 2, 2, 4, 4, 8), space_down=(2, 2, 2, 2, 1, 1), time_down=(1, 2, 2, 1, 1, 1), num_res_blocks=2, in_channels=3, z_channels=24, double_z=True)
self.quant_conv = nn.Conv3d(48, 48, 1, device=device)
self.register_buffer("latents_mean", torch.tensor(LATENTS_MEAN), persistent=False)
self.register_buffer("latents_std", torch.tensor(LATENTS_STD), persistent=False)
self.register_buffer("pixel_mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1, 1), persistent=False)
self.register_buffer("pixel_std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1, 1), persistent=False)
@classmethod
def from_safetensors(cls, path: str | Path, *, device: str | torch.device = "cuda", tiling: bool = True) -> "MiniMaxH3VideoVAEEncoder":
model = cls(device="meta", tiling=tiling)
expected = model.state_dict()
if os.getenv("H3_FAST_SAFETENSORS", "").lower() in {"1", "true", "yes", "on"}:
from fastsafetensors import fastsafe_open
fast_device = "cuda:0" if str(device) == "cuda" else str(device)
available = set()
with fastsafe_open(filenames=[str(path)], nogds=True, device=fast_device) as checkpoint:
available = set(checkpoint.keys())
missing = sorted(set(expected) - available)
weights = {name: checkpoint.get_tensor(name).clone().detach() for name in expected}
if missing:
raise ValueError(f"incompatible H3 VAE checkpoint; missing: {', '.join(missing)}")
weights = {name: t.to(device=device, dtype=torch.float32) for name, t in weights.items()}
elif os.getenv("H3_DISABLE_MMAP", "").lower() in {"1", "true", "yes", "on"}:
from safetensors.torch import load
with open(path, "rb") as file:
available_weights = load(file.read())
available = set(available_weights)
missing = sorted(set(expected) - available)
if missing:
raise ValueError(f"incompatible H3 VAE checkpoint; missing: {', '.join(missing)}")
weights = {name: available_weights[name].to(device=device, dtype=torch.float32) for name in expected}
else:
with safe_open(str(path), framework="pt", device=str(device)) as checkpoint:
available = set(checkpoint.keys())
shape_errors = [(name, tuple(expected[name].shape), tuple(checkpoint.get_slice(name).get_shape())) for name in expected if name in available and tuple(expected[name].shape) != tuple(checkpoint.get_slice(name).get_shape())]
if shape_errors:
raise ValueError(f"incompatible H3 VAE checkpoint; shape mismatch: {shape_errors}")
weights = {name: checkpoint.get_tensor(name).to(dtype=torch.float32) for name in expected}
missing = sorted(set(expected) - available)
if missing:
raise ValueError(f"incompatible H3 VAE checkpoint; missing: {', '.join(missing)}")
model.load_state_dict(weights, strict=True, assign=True)
return model
def _encode_moments(self, x: torch.Tensor) -> torch.Tensor:
return self.quant_conv(self.encoder(x))
def _adaptive_encode(self, x: torch.Tensor) -> torch.Tensor:
if self.tiling:
return self.tiled_encode(x)
return self._encode_moments(x)
def split_tiles(self, length: int) -> tuple[list[int], list[int], list[int]]:
if self.tile_size >= length:
return [0], [length], []
count = math.ceil(length / self.tile_size)
while self.tile_size * count - self.tile_overlap_min * (count - 1) < length:
count += 1
overlaps = [self.tile_overlap_min] * (count - 1)
for index in range((self.tile_size * count - sum(overlaps) - length) // self.vae_ratio):
overlaps[index % len(overlaps)] += self.vae_ratio
starts = [0]
for overlap in overlaps:
starts.append(starts[-1] + self.tile_size - overlap)
return starts, [self.tile_size] * count, overlaps
@staticmethod
def blend(a: torch.Tensor, b: torch.Tensor, extent: int, dim: int) -> torch.Tensor:
extent = min(a.shape[dim], b.shape[dim], extent)
positions = torch.arange(extent, device=b.device, dtype=b.dtype)
weight_a = 1 - positions / extent
weight_b = positions / extent
shape = [1] * a.ndim
shape[dim] = extent
weight_a = weight_a.view(shape)
weight_b = weight_b.view(shape)
slice_a = [slice(None)] * a.ndim
slice_a[dim] = slice(-extent, None)
slice_b = [slice(None)] * a.ndim
slice_b[dim] = slice(0, extent)
blended = a[tuple(slice_a)] * weight_a + b[tuple(slice_b)] * weight_b
if extent < b.shape[dim]:
slice_b_rest = [slice(None)] * b.ndim
slice_b_rest[dim] = slice(extent, None)
return torch.cat((blended, b[tuple(slice_b_rest)]), dim=dim)
return blended
def tiled_encode(self, x: torch.Tensor) -> torch.Tensor:
height, width = x.shape[-2], x.shape[-1]
y_idx, y_len, y_overlap = self.split_tiles(height)
x_idx, x_len, x_overlap = self.split_tiles(width)
rows = [[self._encode_moments(x[..., i_pos:i_pos + i_len, j_pos:j_pos + j_len]) for j_pos, j_len in zip(x_idx, x_len)] for i_pos, i_len in zip(y_idx, y_len)]
latent_y_overlap = [o // self.vae_ratio for o in y_overlap]
latent_x_overlap = [o // self.vae_ratio for o in x_overlap]
result_rows = []
for i, row in enumerate(rows):
result_row = []
for j, tile in enumerate(row):
if i > 0:
tile = self.blend(rows[i - 1][j], tile, latent_y_overlap[i - 1], dim=-2)
if j > 0:
tile = self.blend(row[j - 1], tile, latent_x_overlap[j - 1], dim=-1)
if i < len(rows) - 1:
tile = tile[..., :-latent_y_overlap[i], :]
if j < len(row) - 1:
tile = tile[..., :, :-latent_x_overlap[j]]
result_row.append(tile)
result_rows.append(torch.cat(result_row, dim=-1))
return torch.cat(result_rows, dim=-2)
def encode_temporal(self, x: torch.Tensor) -> torch.Tensor:
if x.shape[2] % self.clip_length != 0:
pad_size = (-x.shape[2]) % self.clip_length
x = torch.cat([x, x[:, :, -1:].repeat(1, 1, pad_size, 1, 1)], dim=2)
num_chunks = x.shape[2] // self.clip_length
z_list = [self._adaptive_encode(x[:, :, i * self.clip_length:(i + 1) * self.clip_length, :, :]) for i in range(num_chunks)]
z = torch.cat(z_list, dim=2)
if self.token_drop > 0:
z = z[:, :, :-self.token_drop]
return z
def encode(self, x: torch.Tensor) -> torch.Tensor:
"""``[B, 3, T, H, W]`` pixels in ``[-1, 1]`` -> normalized latents ``[B, 24, T_lat, H//16, W//16]``."""
if x.ndim == 4:
x = x.unsqueeze(2)
x = x.add(1.0).mul_(0.5).sub_(self.pixel_mean.to(x)).div_(self.pixel_std.to(x))
if x.shape[2] == 1:
moments = self._adaptive_encode(x)[:, :, -1:, :, :]
else:
moments = self.encode_temporal(x)
mean = torch.chunk(moments.float(), 2, dim=1)[0]
latents_mean = self.latents_mean.view(1, -1, 1, 1, 1).to(mean)
latents_std = self.latents_std.view(1, -1, 1, 1, 1).to(mean)
return (mean - latents_mean) / latents_std

View file

@ -7,6 +7,7 @@ import subprocess
import time
from datetime import datetime, timezone
import warnings
import numpy as np
warnings.filterwarnings("ignore", message="Found GPU0 NVIDIA GB10 which is of cuda capability 12.1.*", category=UserWarning)
@ -21,8 +22,10 @@ from h3_blackwell_runtime.packing import H3PromptPacker
from h3_blackwell_runtime.qwen3vl_text import Qwen3VLPromptConditioner
from h3_blackwell_runtime.sampler import sample_video_res_multistep
from h3_blackwell_runtime.t2v import random_av_latents
from h3_blackwell_runtime.qwen3vl_vision import build_fl2va_presentation, resize_keyframe
from h3_blackwell_runtime.token_refiner import H3TokenRefiner
from h3_blackwell_runtime.vae_decoder import MiniMaxH3VideoVAE, dtype_from_name
from h3_blackwell_runtime.vae_encoder import MiniMaxH3VideoVAEEncoder
parser = argparse.ArgumentParser()
@ -33,6 +36,8 @@ parser.add_argument("--height", type=int, default=192)
parser.add_argument("--frames", type=int, default=22)
parser.add_argument("--steps", type=int, default=12)
parser.add_argument("--seed", type=int, default=440204)
parser.add_argument("--first-frame", type=Path, help="First keyframe image (fl2va), PNG/JPG path.")
parser.add_argument("--last-frame", type=Path, help="Last keyframe image (fl2va), PNG/JPG path.")
parser.add_argument("--attention", choices=AVAILABLE_BACKENDS, default=DEFAULT_ATTENTION_BACKEND)
parser.add_argument("--model-timesteps-capture", type=Path, help="Directory containing captured input_XX.pt H3 timesteps for strict parity checks.")
parser.add_argument("--progress", action="store_true", help="Print per-step sampler progress.")
@ -97,8 +102,6 @@ video, audio, frames = random_av_latents(args.width, args.height, args.frames, a
model = H3PackedDenoiser.from_checkpoint(checkpoint, attention_backend=args.attention).eval()
configure_mlp_chunking(model, args.mlp_chunks, args.mlp_chunk_threshold)
report_memory("h3_loaded")
text = H3TokenRefiner(checkpoint, attention_backend=args.attention)(conditioner(args.prompt))
report_memory("text_conditioned")
model_timesteps = None
if args.model_timesteps_capture is not None:
model_timesteps = [
@ -107,23 +110,88 @@ if args.model_timesteps_capture is not None:
]
want_audio = args.save_audio_latent is not None or args.audio_output is not None or args.mux_audio
cache_stats = {}
sampled = sample_video_res_multistep(
model,
H3PromptPacker(checkpoint),
text,
video,
audio,
steps=args.steps,
model_timesteps=model_timesteps,
return_audio=want_audio,
progress=args.progress,
cache_mode=args.cache_mode,
cache_threshold=args.cache_threshold,
cache_start_percent=args.cache_start_percent,
cache_end_percent=args.cache_end_percent,
cache_subsample_factor=args.cache_subsample_factor,
cache_stats=cache_stats,
)
refiner = H3TokenRefiner(checkpoint, attention_backend=args.attention)
packer = H3PromptPacker(checkpoint)
if args.first_frame is not None or args.last_frame is not None:
from PIL import Image
def load_image(path: Path) -> torch.Tensor:
img = Image.open(path).convert("RGB")
return torch.from_numpy(np.array(img)).permute(2, 0, 1).unsqueeze(0).float() / 255.0
first = load_image(args.first_frame) if args.first_frame is not None else None
last = load_image(args.last_frame) if args.last_frame is not None else None
vae_encoder = MiniMaxH3VideoVAEEncoder.from_safetensors("/vae/minimax_h3_video_vae_fp16.safetensors", device="cuda").eval()
report_memory("vae_encoder_loaded")
from h3_blackwell_runtime.qwen3vl_vision import Qwen3VL32BVision
vision_tower = Qwen3VL32BVision(
"/text-encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", device="cuda", dtype=torch.bfloat16
)
report_memory("vision_tower_loaded")
presentation = build_fl2va_presentation(
args.prompt,
first,
last,
width=args.width,
height=args.height,
frame_count=frames,
tokenizer=conditioner.tokenizer,
vision=vision_tower,
text_encoder=conditioner.encoder,
device="cuda",
)
cond_latents = []
for kf in presentation.keyframes:
resized = resize_keyframe(kf["image"].cuda(), args.width, args.height, crop="disabled" if kf["resolved_frame_index"] == 0 else "center")
cond_latents.append(vae_encoder.encode(resized.movedim(-1, 1).cuda().float()))
report_memory("fl2va_conditioned")
text = refiner(presentation.text_states)
report_memory("text_conditioned")
seed = args.seed
sampled = sample_video_res_multistep(
model,
packer,
text,
video,
audio,
steps=args.steps,
model_timesteps=model_timesteps,
return_audio=want_audio,
progress=args.progress,
seed=seed,
text_token_tags=presentation.text_token_tags,
cond_latents=cond_latents,
frame_count=frames,
cache_mode=args.cache_mode,
cache_threshold=args.cache_threshold,
cache_start_percent=args.cache_start_percent,
cache_end_percent=args.cache_end_percent,
cache_subsample_factor=args.cache_subsample_factor,
cache_stats=cache_stats,
)
else:
text = refiner(conditioner(args.prompt))
report_memory("text_conditioned")
sampled = sample_video_res_multistep(
model,
packer,
text,
video,
audio,
steps=args.steps,
model_timesteps=model_timesteps,
return_audio=want_audio,
progress=args.progress,
seed=args.seed,
cache_mode=args.cache_mode,
cache_threshold=args.cache_threshold,
cache_start_percent=args.cache_start_percent,
cache_end_percent=args.cache_end_percent,
cache_subsample_factor=args.cache_subsample_factor,
cache_stats=cache_stats,
)
if cache_stats:
report({"cache": cache_stats})
if want_audio:

View file

@ -10,10 +10,44 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import torch
from PIL import Image
from h3_blackwell_runtime.attention import AVAILABLE_BACKENDS, DEFAULT_ATTENTION_BACKEND
from h3_blackwell_runtime.runtime import H3HotRuntime, RuntimeConfig
def _load_image(value) -> torch.Tensor | None:
"""Accept a keyframe as on-disk path or base64 JPEG/PNG -> ``[1,3,H,W]`` float ``[0,1]``."""
if value in (None, ""):
return None
if isinstance(value, (list, tuple)):
value = value[0]
if isinstance(value, dict):
value = value.get("url") or value.get("path") or value.get("b64")
if isinstance(value, str) and "\n" not in value and len(value) < 2048 and not value.startswith("data:"):
path = Path(value)
if path.exists():
image = Image.open(path).convert("RGB")
import numpy as np
tensor = torch.from_numpy(np.array(image))[None].permute(0, 3, 1, 2).float() / 255.0
return tensor
data = value
if isinstance(value, str) and value.startswith("data:"):
data = value.split(",", 1)[1]
if isinstance(data, str) and len(data) >= 1024:
import base64
import io
raw = base64.b64decode(data)
image = Image.open(io.BytesIO(raw)).convert("RGB")
import numpy as np
return torch.from_numpy(np.array(image))[None].permute(0, 3, 1, 2).float() / 255.0
raise ValueError("first_frame/last_frame must be a path or a base64/data-URL image")
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
@ -91,6 +125,8 @@ class Handler(BaseHTTPRequestHandler):
return
mux_audio = bool(payload.get("mux_audio", True))
ffmpeg_loglevel = payload.get("ffmpeg_loglevel", "error")
first_frame = _load_image(payload.get("first_frame"))
last_frame = _load_image(payload.get("last_frame"))
save_latent = payload.get("save_latent")
cache_mode = payload.get("cache_mode")
cache_threshold = float(payload.get("cache_threshold", 0.0))
@ -108,6 +144,8 @@ class Handler(BaseHTTPRequestHandler):
steps=steps,
seed=seed,
attention=attention,
first_frame=first_frame,
last_frame=last_frame,
mux_audio=mux_audio,
ffmpeg_loglevel=ffmpeg_loglevel,
save_latent=save_latent,