Add support for video references in MiniMax H3 ref2va

This commit is contained in:
Jaret Burkett 2026-08-15 06:18:09 -06:00
parent 4900e5e866
commit 97bf49edad
10 changed files with 668 additions and 74 deletions

View File

@ -62,6 +62,8 @@ from toolkit.util.quantize import get_qtype, quantize, quantize_model
from optimum.quanto import freeze
from .src import packing
packing_video_exts = [".mp4", ".avi", ".mov", ".webm", ".mkv", ".wmv", ".m4v", ".flv"]
from .src.audio_vae import MiniMaxH3AudioVAE, fold_audio_vae_weight_norm
from .src.packing import (
KEYFRAME_ENCODE_SEED,
@ -75,7 +77,13 @@ from .src.packing import (
unpatchify_video_tokens,
)
from .src.pipeline import MiniMaxH3Pipeline
from .src.text_encoder import TEXT_ENCODER_LAYER, encode_minimax_h3_prompt
from .src.ref_video_cache import load_ref_video_latent
from .src.text_encoder import (
TEXT_ENCODER_LAYER,
VideoRef,
encode_minimax_h3_prompt,
load_video_ref,
)
from .src.transformer import MiniMaxH3Transformer, MiniMaxH3TransformerParams
from .src.vae import MiniMaxH3VideoVAE
@ -664,6 +672,9 @@ class MinimaxH3Model(BaseModel):
pil_images.append(
Image.fromarray(arr.permute(1, 2, 0).cpu().numpy())
)
elif isinstance(img, str):
# a control VIDEO path: 2 fps timestamped presentation
pil_images.append(load_video_ref(img))
else:
pil_images.append(img)
if len(pil_images) == 1:
@ -824,7 +835,7 @@ class MinimaxH3Model(BaseModel):
"""Build the packed sequence's condition segment for one train step.
``latent_shape`` is the target's ``(t_lat, h_lat, w_lat)``. Returns
``(cond_rows, keyframe_anchors, image_ref_shapes)`` where ``cond_rows``
``(cond_rows, cond_audio_rows, keyframe_anchors, ref_blocks)`` where ``cond_rows``
is ``(B, num_condition_rows, 96)`` or None. The base model implements
fl2va: the clip's first frame as a keyframe when the dataset asks for
i2v. MinimaxH3Ref2VAModel overrides this with image references from
@ -835,7 +846,7 @@ class MinimaxH3Model(BaseModel):
and getattr(batch, "num_frames", 1) > 1
)
if not do_i2v:
return None, (), ()
return None, None, (), ()
if batch.first_frame_latents is not None:
first_latents = batch.first_frame_latents.to(device, torch.float32)
@ -857,7 +868,7 @@ class MinimaxH3Model(BaseModel):
KEYFRAME_NOISE_AUG_T * first_latents
+ (1.0 - KEYFRAME_NOISE_AUG_T) * cond_noise
)
return patchify_video_latents(first_latents).to(dtype), ("first",), ()
return patchify_video_latents(first_latents).to(dtype), None, ("first",), ()
def get_noise_prediction(
self,
@ -896,9 +907,12 @@ class MinimaxH3Model(BaseModel):
t_a = 1.0 - sigma_a
# --- conditioning rows (fl2va keyframe / ref2va references) ----
cond_rows, keyframe_anchors, image_ref_shapes = self._build_condition(
batch, (t_lat, h_lat, w_lat), device, dtype
)
(
cond_rows,
cond_audio_rows,
keyframe_anchors,
ref_blocks,
) = self._build_condition(batch, (t_lat, h_lat, w_lat), device, dtype)
# --- audio rows -------------------------------------------------
if batch is not None and getattr(batch, "num_frames", None):
@ -983,7 +997,7 @@ class MinimaxH3Model(BaseModel):
latent_width=w_lat,
num_audio_latents=a_lat,
keyframe_anchors=keyframe_anchors,
image_ref_shapes=image_ref_shapes,
ref_blocks=ref_blocks,
)
)
(
@ -995,14 +1009,20 @@ class MinimaxH3Model(BaseModel):
_,
) = pad_layouts_to_batch(layouts)
num_cond = layouts[0].num_condition_video_rows
num_cond_audio = layouts[0].num_condition_audio_rows
# per-row timesteps: text/video rows at t_v, audio rows at t_a,
# condition rows pinned at max(t_v, 0.999)
# condition rows pinned at max(t_v, 0.999); ref soundtracks clean
row_t = t_v.view(-1, 1).expand(-1, token_tags.shape[1]).clone()
row_t[:, audio_indices] = t_a.view(-1, 1)
if num_cond > 0:
cond_t = torch.maximum(t_v, torch.full_like(t_v, KEYFRAME_NOISE_AUG_T))
row_t[:, video_indices[:num_cond]] = cond_t.view(-1, 1)
if num_cond_audio > 0:
row_t[:, audio_indices[:num_cond_audio]] = 1.0
audio_rows = torch.cat(
[cond_audio_rows.to(audio_rows.dtype), audio_rows], dim=1
)
# pad text embeds to the batch max length
max_text = int(text_indices.shape[0])
@ -1034,6 +1054,9 @@ class MinimaxH3Model(BaseModel):
text_indices=text_indices.to(device),
)
if num_cond_audio > 0:
# reference soundtrack rows are conditioning, not targets
audio_pred = audio_pred[:, num_cond_audio:]
if batch is not None and batch.audio_target is not None:
# flip to ai-toolkit's noise - clean convention
if is_primary_pred:
@ -1199,6 +1222,9 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
# their ORIGINAL size — this model does its own area-matched resize
self.has_multiple_control_images = True
self.use_raw_control_images = True
# control VIDEOS are cached like dataset items and consumed as
# multi-frame reference blocks
self.supports_video_control_images = True
def _dit_component(self) -> str:
partition = str(
@ -1220,7 +1246,7 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
# raw-size control tensors in [0, 1]; each is resized to the TARGET's
# pixel area with its own aspect kept, then encoded on its own grid
if batch is None:
return None, (), ()
return None, None, (), ()
controls_per_item = None
if batch.control_tensor_list is not None:
controls_per_item = batch.control_tensor_list
@ -1229,7 +1255,11 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
[batch.control_tensor[b]] for b in range(batch.control_tensor.shape[0])
]
if not controls_per_item:
return None, (), ()
controls_per_item = (
[[] for _ in batch.file_items] if batch.file_items else []
)
if not controls_per_item:
return None, None, (), ()
ref_count = len(controls_per_item[0])
if any(len(c) != ref_count for c in controls_per_item):
raise ValueError(
@ -1278,7 +1308,151 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
)
ref_shapes.append((ref_latents.shape[3], ref_latents.shape[4]))
all_rows.append(patchify_video_latents(ref_latents).to(dtype))
return torch.cat(all_rows, dim=1), (), tuple(ref_shapes)
blocks = [(1, h, w, 0) for h, w in ref_shapes]
audio_rows = []
self._append_video_ref_blocks(
batch, all_rows, audio_rows, blocks, device, dtype
)
if not all_rows:
return None, None, (), ()
cond_audio = torch.cat(audio_rows, dim=1) if audio_rows else None
return torch.cat(all_rows, dim=1), cond_audio, (), tuple(blocks)
@torch.no_grad()
def _encode_ref_video_for_sampling(self, path: str, gen_config) -> torch.Tensor:
"""Decode a reference video, sample it evenly onto the 17n+5 grid
(capped at the sample's frame count), area-match it to the target with
its own aspect, and encode with the released keyframe recipe. Returns
normalized latents (C, T, h, w)."""
import cv2
import numpy as np
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise ValueError(f"Could not open reference video {path}")
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
n = packing.align_num_frames_down(min(total, max(gen_config.num_frames, 5)))
indices = [round(i * (total - 1) / max(n - 1, 1)) for i in range(n)]
frames = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok:
break
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
cap.release()
if len(frames) != n:
n = packing.align_num_frames_down(max(len(frames), 5))
frames = frames[:n]
h0, w0 = frames[0].shape[:2]
ph, pw = packing.reference_pixel_size(
w0, h0, gen_config.height, gen_config.width
)
pixels = torch.from_numpy(np.stack(frames)).float() / 255.0 * 2.0 - 1.0
pixels = pixels.permute(3, 0, 1, 2)[None] # (1, 3, T, H, W)
pixels = (
torch.nn.functional.interpolate(
pixels.reshape(1 * 3, len(frames), h0, w0).transpose(0, 1),
size=(ph, pw),
mode="bilinear",
antialias=True,
)
.transpose(0, 1)
.reshape(1, 3, len(frames), ph, pw)
)
generator = torch.Generator(device="cpu").manual_seed(KEYFRAME_ENCODE_SEED)
latents = self.video_vae.encode(
pixels.to(self.vae.device, self.video_vae.dtype),
sample=True,
generator=generator,
fp16_round=True,
)
# soundtrack rides clean when the clip has one (best effort)
audio_rows = None
try:
import torchaudio
waveform, sample_rate = torchaudio.load(path)
rows = self.encode_audio(
[{"waveform": waveform, "sample_rate": sample_rate}]
)[0]
a_lat = packing.audio_latent_num_frames(n)
audio_rows = self._fit_audio_rows(rows.float(), a_lat)
except Exception:
pass
return {"latent": latents[0].float(), "audio_rows": audio_rows}
def _append_video_ref_blocks(
self, batch, all_rows, audio_rows, blocks, device, dtype
):
# control videos get dataset-identical treatment (frame count, fps,
# bucket) and one VAE encode, disk-cached next to the video; the
# resulting latents become multi-frame reference blocks packed after
# the image references
paths_per_item = getattr(batch, "control_video_paths_list", None)
if not paths_per_item:
return
vid_count = len(paths_per_item[0])
if any(len(v) != vid_count for v in paths_per_item):
raise ValueError(
"ref2va: every item in a batch must have the same number of "
"reference videos"
)
for ref_idx in range(vid_count):
lats = []
auds = []
for per_item in paths_per_item:
entry = load_ref_video_latent(
self, per_item[ref_idx], batch.dataset_config
)
lats.append(entry["latent"].to(device, torch.float32))
auds.append(entry.get("audio_rows"))
shapes = {tuple(l.shape) for l in lats}
if len(shapes) > 1:
raise ValueError(
"ref2va: reference video latent shapes must match across a "
f"batch (got {sorted(shapes)}); use batch_size 1"
)
ref_latents = torch.stack(lats) # (B, C, T, h, w)
ref_noise = torch.randn_like(ref_latents)
ref_latents = (
KEYFRAME_NOISE_AUG_T * ref_latents
+ (1.0 - KEYFRAME_NOISE_AUG_T) * ref_noise
)
# soundtrack rows ride clean when every item in the batch has them
a_lat = 0
if all(a is not None for a in auds):
a_lat = packing.audio_latent_num_frames(entry["num_frames"])
trimmed = [
self._fit_audio_rows(a.to(device, torch.float32), a_lat)
for a in auds
]
if len({t.shape for t in trimmed}) == 1:
audio_rows.append(torch.stack(trimmed).to(dtype))
else:
a_lat = 0
blocks.append(
(
ref_latents.shape[2],
ref_latents.shape[3],
ref_latents.shape[4],
a_lat,
)
)
all_rows.append(patchify_video_latents(ref_latents).to(dtype))
@staticmethod
def _fit_audio_rows(rows: torch.Tensor, a_lat: int) -> torch.Tensor:
"""Trim/pad channel-major packed rows (2*T, C) to 2*a_lat rows,
per stereo channel."""
t = rows.shape[0] // 2
per_ch = rows.reshape(2, t, rows.shape[-1])
if t > a_lat:
per_ch = per_ch[:, :a_lat]
elif t < a_lat:
per_ch = torch.nn.functional.pad(per_ch, (0, 0, 0, a_lat - t))
return per_ch.reshape(2 * a_lat, rows.shape[-1])
def generate_single_image(
self,
@ -1304,8 +1478,9 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
gen_config.log_image = partial(blank_log_image_function, gen_config)
gen_config.output_ext = "mp4"
# every ctrl image is a REFERENCE (never a first frame): resized to
# the target's pixel area with its own aspect kept
# every ctrl file is a REFERENCE (never a first frame): images are
# resized to the target's pixel area with their own aspect kept;
# videos are dataset-style encoded into multi-frame latent blocks
ref_images = []
for path in (
gen_config.ctrl_img,
@ -1313,7 +1488,13 @@ class MinimaxH3Ref2VAModel(MinimaxH3Model):
gen_config.ctrl_img_2,
gen_config.ctrl_img_3,
):
if path is not None:
if path is None:
continue
if os.path.splitext(str(path))[1].lower() in packing_video_exts:
ref_images.append(
self._encode_ref_video_for_sampling(str(path), gen_config)
)
else:
img = Image.open(path).convert("RGB")
ref_images.append(
packing.prepare_reference_image(

View File

@ -269,6 +269,7 @@ class PackedLayout:
audio_indices: torch.Tensor
text_indices: torch.Tensor
num_condition_video_rows: int
num_condition_audio_rows: int = 0
def build_packed_sequence(
@ -279,24 +280,32 @@ def build_packed_sequence(
num_audio_latents: int,
patch_size=(1, 2, 2),
keyframe_anchors: Tuple[str, ...] = (),
image_ref_shapes: Tuple[Tuple[int, int], ...] = (),
ref_blocks: Tuple[Tuple[int, int, int], ...] = (),
) -> PackedLayout:
"""Build the [text | conditions | target audio | target video] layout.
The condition segment holds either fl2va keyframes (``keyframe_anchors``:
rows pinned at the first/last target frame's rotary time, on the target's
spatial grid) or ref2va image references (``image_ref_shapes``: one
``(latent_height, latent_width)`` per reference references keep their
OWN aspect on their own aspect-normalized grid; each block sits at its
own rotary time and advances the shared media clock by 1.0, so the target
streams start at ``num_text + len(image_ref_shapes)``)."""
if keyframe_anchors and image_ref_shapes:
raise ValueError("keyframe_anchors and image_ref_shapes are mutually exclusive")
spatial grid) or ref2va references (``ref_blocks``: one
``(t_lat, latent_height, latent_width)`` per reference ``t_lat == 1``
for images. References keep their OWN aspect on their own
aspect-normalized grid; an image block advances the shared media clock by
1.0, a video block by its temporal span, and the target streams start
after the cumulative advance)."""
if keyframe_anchors and ref_blocks:
raise ValueError("keyframe_anchors and ref_blocks are mutually exclusive")
_, ph, pw = patch_size
rows_per_frame = (latent_height // ph) * (latent_width // pw)
num_text = int(text_token_tags.shape[0])
ref_rows = [(h // ph) * (w // pw) for h, w in image_ref_shapes]
num_cond = len(keyframe_anchors) * rows_per_frame + sum(ref_rows)
# a ref block is (t_lat, h, w) or (t_lat, h, w, audio_latents): a video
# reference's soundtrack packs as clean audio rows immediately BEFORE its
# own video rows
ref_blocks = tuple(tuple(b) + (0,) * (4 - len(b)) for b in ref_blocks)
ref_vid_rows = [t * (h // ph) * (w // pw) for t, h, w, _ in ref_blocks]
ref_aud_rows = [a * AUDIO_CHANNELS for _, _, _, a in ref_blocks]
num_cond = (
len(keyframe_anchors) * rows_per_frame + sum(ref_vid_rows) + sum(ref_aud_rows)
)
num_audio_rows = num_audio_latents * AUDIO_CHANNELS
num_video_rows = num_latent_frames * rows_per_frame
seq_len = num_text + num_cond + num_audio_rows + num_video_rows
@ -305,10 +314,15 @@ def build_packed_sequence(
audio_start = cond_start + num_cond
video_start = audio_start + num_audio_rows
def _block_advance(t, a):
span = 1.0 if t == 1 else _temporal_position_span(t)
return max(span, float(a)) if a else span
# text rows sit on the time axis at their row index; the media clock
# continues from there (past the reference blocks, which advance it 1.0
# each), so prompt length shifts the whole media clock
media_origin = float(num_text + len(image_ref_shapes))
# continues from there past the reference blocks, so prompt length (and
# reference count/length) shifts the whole media clock
media_advance = sum(_block_advance(t, a) for t, _, _, a in ref_blocks)
media_origin = float(num_text) + media_advance
position_ids = torch.zeros(seq_len, 3, dtype=torch.float64)
position_ids[:num_text, 0] = torch.arange(num_text, dtype=torch.float64)
@ -340,25 +354,50 @@ def build_packed_sequence(
position_ids[rows, 1:] = frame_grid
ref_cursor = cond_start
for i, (ref_h, ref_w) in enumerate(image_ref_shapes):
ref_clock = float(num_text)
cond_audio_idx = []
cond_video_idx = []
if keyframe_anchors:
cond_video_idx.append(torch.arange(cond_start, audio_start))
ref_cursor = audio_start
for i, (ref_t, ref_h, ref_w, ref_a) in enumerate(ref_blocks):
# each reference on its own aspect-normalized grid (area-matched to
# the target, so the grids span comparable ranges)
ref_sqrt_area = math.sqrt(ref_h * ref_w)
w_grid = _spatial_position_grid(ref_w, pw, ref_sqrt_area)
ref_grid = torch.stack(
[
g.reshape(-1)
for g in torch.meshgrid(
_spatial_position_grid(ref_h, ph, ref_sqrt_area),
_spatial_position_grid(ref_w, pw, ref_sqrt_area),
w_grid,
indexing="ij",
)
],
dim=-1,
)
rows = slice(ref_cursor, ref_cursor + ref_rows[i])
position_ids[rows, 0] = float(num_text + i)
position_ids[rows, 1:] = ref_grid
ref_cursor += ref_rows[i]
if ref_a:
# soundtrack rows first: channel-major, shared 40/s clock from the
# block origin, width pinned to the ref grid's extremes
a_time = ref_clock + torch.arange(ref_a, dtype=torch.float64)
rows = slice(ref_cursor, ref_cursor + ref_aud_rows[i])
position_ids[rows, 0] = a_time.repeat(AUDIO_CHANNELS)
position_ids[rows, 2] = torch.cat(
[
torch.full((ref_a,), float(w_grid[0]), dtype=torch.float64),
torch.full((ref_a,), float(w_grid[-1]), dtype=torch.float64),
]
)
cond_audio_idx.append(torch.arange(rows.start, rows.stop))
ref_cursor += ref_aud_rows[i]
rows_per_ref_frame = ref_grid.shape[0]
block = torch.empty(ref_t, rows_per_ref_frame, 3, dtype=torch.float64)
block[:, :, 0] = _temporal_position_grid(ref_t, ref_clock)[:, None]
block[:, :, 1:] = ref_grid[None]
position_ids[ref_cursor : ref_cursor + ref_vid_rows[i]] = block.reshape(-1, 3)
cond_video_idx.append(torch.arange(ref_cursor, ref_cursor + ref_vid_rows[i]))
ref_cursor += ref_vid_rows[i]
ref_clock += _block_advance(ref_t, ref_a)
# audio rows: channel-major, one rotary unit per latent (40/s = 24fps*5/3),
# no height coordinate, width pinned to the grid extremes per channel
@ -380,10 +419,10 @@ def build_packed_sequence(
video_pos[:, :, 1:] = frame_grid[None]
position_ids[video_start:] = video_pos.reshape(-1, 3)
video_indices = torch.cat(
[torch.arange(cond_start, audio_start), torch.arange(video_start, seq_len)]
)
audio_indices = torch.arange(audio_start, video_start)
num_cond_video = sum(int(x.shape[0]) for x in cond_video_idx)
num_cond_audio = sum(int(x.shape[0]) for x in cond_audio_idx)
video_indices = torch.cat(cond_video_idx + [torch.arange(video_start, seq_len)])
audio_indices = torch.cat(cond_audio_idx + [torch.arange(audio_start, video_start)])
text_indices = torch.arange(num_text)
token_tags = torch.empty(seq_len, dtype=torch.long)
@ -398,7 +437,8 @@ def build_packed_sequence(
video_indices=video_indices,
audio_indices=audio_indices,
text_indices=text_indices,
num_condition_video_rows=num_cond,
num_condition_video_rows=num_cond_video,
num_condition_audio_rows=num_cond_audio,
)
@ -419,6 +459,8 @@ def build_row_timesteps(
condition_video_timestep
)
row_t[layout.audio_indices] = float(audio_timestep)
# reference soundtracks ride clean
row_t[layout.audio_indices[: layout.num_condition_audio_rows]] = 1.0
return row_t

View File

@ -108,9 +108,20 @@ class MiniMaxH3Pipeline:
raise ValueError("ctrl_img (first frame) and ref_images are exclusive")
anchors = ("first",) if ctrl_img is not None else ()
# references keep their own aspect: latent dims come from each image
ref_shapes = tuple(
(img.size[1] // 16, img.size[0] // 16) for img in (ref_images or [])
)
# (PIL images are single-frame blocks; video refs arrive as latent
# tensors (C, T, h, w) already encoded by the caller)
ref_blocks = []
for r in ref_images or []:
if isinstance(r, dict):
lat = r["latent"]
a = r.get("audio_rows")
a_lat = int(a.shape[0]) // 2 if a is not None else 0
ref_blocks.append((lat.shape[1], lat.shape[2], lat.shape[3], a_lat))
elif isinstance(r, torch.Tensor):
ref_blocks.append((r.shape[1], r.shape[2], r.shape[3]))
else:
ref_blocks.append((1, r.size[1] // 16, r.size[0] // 16))
ref_blocks = tuple(ref_blocks)
layout = build_packed_sequence(
text_token_tags=token_tags,
num_latent_frames=t_lat,
@ -118,7 +129,7 @@ class MiniMaxH3Pipeline:
latent_width=w_lat,
num_audio_latents=a_lat,
keyframe_anchors=anchors,
image_ref_shapes=ref_shapes,
ref_blocks=ref_blocks,
)
num_cond = layout.num_condition_video_rows
@ -140,13 +151,36 @@ class MiniMaxH3Pipeline:
)
return patchify_video_latents(cond_latents) # (1, rows, 96)
def noise_aug_rows(latents: torch.Tensor) -> torch.Tensor:
cond_noise = randn_tensor(
latents.shape, generator=generator, dtype=torch.float32
).to(device)
mixed = (
KEYFRAME_NOISE_AUG_T * latents.to(device, torch.float32)
+ (1.0 - KEYFRAME_NOISE_AUG_T) * cond_noise
)
return patchify_video_latents(mixed)
cond_rows = None
cond_audio_rows = None
if ctrl_img is not None:
cond_rows = encode_condition_image(ctrl_img)
elif ref_images:
cond_rows = torch.cat(
[encode_condition_image(img) for img in ref_images], dim=1
)
parts = []
audio_parts = []
for r in ref_images:
if isinstance(r, dict):
# pre-encoded video reference (+ optional clean soundtrack)
parts.append(noise_aug_rows(r["latent"][None]))
if r.get("audio_rows") is not None:
audio_parts.append(r["audio_rows"][None].to(device))
elif isinstance(r, torch.Tensor):
parts.append(noise_aug_rows(r[None]))
else:
parts.append(encode_condition_image(r))
cond_rows = torch.cat(parts, dim=1)
if audio_parts:
cond_audio_rows = torch.cat(audio_parts, dim=1).float()
# --- initial noise -------------------------------------------------
if latents is None:
@ -186,10 +220,13 @@ class MiniMaxH3Pipeline:
video_in = video_rows
if cond_rows is not None:
video_in = torch.cat([cond_rows, video_rows], dim=1)
audio_in = audio_rows
if cond_audio_rows is not None:
audio_in = torch.cat([cond_audio_rows, audio_rows], dim=1)
video_pred, audio_pred = transformer(
hidden_states=video_in.to(dtype),
audio_hidden_states=audio_rows.to(dtype),
audio_hidden_states=audio_in.to(dtype),
encoder_hidden_states=text_embeds[None],
row_timesteps=row_t,
token_tags=tags,
@ -199,7 +236,7 @@ class MiniMaxH3Pipeline:
text_indices=text_indices,
)
v_video = video_pred[:, num_cond:].float()
v_audio = audio_pred.float()
v_audio = audio_pred[:, layout.num_condition_audio_rows :].float()
denoised_v = video_rows + sv * v_video
ratio_v = sv_next / sv

View File

@ -0,0 +1,159 @@
"""Reference-video latents for ref2va, without dataloader machinery.
A control VIDEO gets the dataset's treatment — num_frames / auto_frame_count,
fps, resolution bucket with center crop then a single VAE encode whose
result is cached next to the video in ``_latent_cache/`` (keyed like normal
latent caches: file signature + the config values that shape the latent).
Everything is deterministic (even frame spread, no random start) so the cache
is stable; the audio track is encoded alongside when possible (cached for
later use, unused in conditioning for now).
"""
import base64
import hashlib
import json
import os
import cv2
import numpy as np
import torch
from safetensors.torch import load_file, save_file
from toolkit.basic import get_quick_signature_string
from toolkit.buckets import get_bucket_for_image_size
def _cache_path(path: str, hash_dict: dict) -> str:
latent_dir = os.path.join(os.path.dirname(path), "_latent_cache")
name = os.path.splitext(os.path.basename(path))[0]
hash_input = json.dumps(hash_dict, sort_keys=True).encode("utf-8")
hash_str = (
base64.urlsafe_b64encode(hashlib.md5(hash_input).digest())
.decode("ascii")
.replace("=", "")
)
return os.path.join(latent_dir, f"{name}_{hash_str}.safetensors")
@torch.no_grad()
def load_ref_video_latent(model, path: str, dataset_config) -> dict:
"""Returns {"latent": (C, T, h, w) cpu tensor, "num_frames": int},
encoding + disk-caching on first use. ``model`` is the MinimaxH3 model
(used for the VAE, audio encode and the frame-count snapper)."""
mem_cache = getattr(model, "_ref_video_cache", None)
if mem_cache is None:
mem_cache = {}
model._ref_video_cache = mem_cache
if path in mem_cache:
return mem_cache[path]
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise ValueError(f"Could not open reference video {path}")
src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
src_fps = cap.get(cv2.CAP_PROP_FPS) or dataset_config.fps
# dataset-identical frame count
if dataset_config.auto_frame_count:
num_frames = int(total / src_fps * dataset_config.fps)
snapper = model.get_frame_count_snapper()
if snapper is not None:
num_frames = snapper(num_frames)
else:
num_frames = dataset_config.num_frames
trim_tail = bool(
dataset_config.auto_frame_count and dataset_config.trim_auto_frame_count_tail
)
hash_dict = {
"signature": get_quick_signature_string(path),
"resolution": dataset_config.resolution,
"num_frames": num_frames,
"fps": dataset_config.fps,
"trim_tail": trim_tail,
"latent_space_version": model.latent_space_version,
"is_ref_video": True,
}
cache_file = _cache_path(path, hash_dict)
if os.path.exists(cache_file):
cap.release()
sd = load_file(cache_file, device="cpu")
entry = {
"latent": sd["latent"],
"num_frames": int(sd["num_frames"].item()),
"audio_rows": sd.get("audio_latent"),
}
mem_cache[path] = entry
return entry
# dataset-identical bucket sizing (center crop, no random)
bucket = get_bucket_for_image_size(
src_w,
src_h,
resolution=dataset_config.resolution,
divisibility=dataset_config.bucket_tolerance,
)
scale = max(bucket["width"] / src_w, bucket["height"] / src_h)
scale_w, scale_h = int(np.ceil(src_w * scale)), int(np.ceil(src_h * scale))
crop_x = (scale_w - bucket["width"]) // 2
crop_y = (scale_h - bucket["height"]) // 2
if trim_tail:
# dataset trim mode: real-time pacing from the start, tail trimmed —
# keeps motion speed honest and the soundtrack in sync
fps_ratio = src_fps / dataset_config.fps if src_fps > 0 else 1.0
indices = [min(round(i * fps_ratio), total - 1) for i in range(num_frames)]
else:
# deterministic even frame spread across the clip
indices = [
min(round(i * (total - 1) / max(num_frames - 1, 1)), total - 1)
for i in range(num_frames)
]
frames = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok:
raise ValueError(f"Could not read frame {idx} of {path}")
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frame = cv2.resize(frame, (scale_w, scale_h), interpolation=cv2.INTER_AREA)
frame = frame[
crop_y : crop_y + bucket["height"], crop_x : crop_x + bucket["width"]
]
frames.append(frame)
cap.release()
pixels = torch.from_numpy(np.stack(frames)).float() / 255.0 * 2.0 - 1.0
pixels = pixels.permute(0, 3, 1, 2) # (T, C, H, W), [-1, 1]
latent = model.encode_images([pixels])[0].to("cpu", torch.float16)
state_dict = {
"latent": latent,
"num_frames": torch.tensor(num_frames, dtype=torch.int64),
}
# the soundtrack rides as clean condition rows; best effort (no track = None)
audio_rows = None
try:
import torchaudio
waveform, sample_rate = torchaudio.load(path)
if trim_tail:
# frames cover [0, num_frames / fps) seconds; trim the soundtrack
# to the same window so it stays in sync with the sampled frames
keep = int(round(num_frames / dataset_config.fps * sample_rate))
waveform = waveform[:, :keep]
audio_latent = model.encode_audio(
[{"waveform": waveform, "sample_rate": sample_rate}]
)[0]
audio_rows = audio_latent.to("cpu", torch.float16)
state_dict["audio_latent"] = audio_rows
except Exception:
pass
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
save_file(state_dict, cache_file)
entry = {"latent": latent, "num_frames": num_frames, "audio_rows": audio_rows}
mem_cache[path] = entry
return entry

View File

@ -19,11 +19,60 @@ from typing import List, Optional
import torch
from dataclasses import dataclass, field
from .packing import TEXT_TAG, VIDEO_TAG
TEXT_ENCODER_LAYER = 50
@dataclass
class VideoRef:
"""A reference VIDEO for the Qwen3-VL presentation: frames sampled at
2 fps with their timestamps (seconds). Presented ComfyUI-style as
``<Video k>: `` plus one ``<T.T seconds>``-stamped vision block per
merged frame pair."""
frames: list = field(default_factory=list) # PIL images
timestamps: list = field(default_factory=list) # float seconds, per frame
def load_video_ref(path, max_frames: int = 0) -> "VideoRef":
"""Sample a video at 2 fps (slot rounding on its native fps) into a
VideoRef with per-frame timestamps in seconds."""
import cv2
from PIL import Image as _Image
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise ValueError(f"Could not open control video {path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
indices = []
i = 0
while True:
idx = round(i * fps / 2.0)
if idx >= total:
break
if not indices or idx != indices[-1]:
indices.append(idx)
i += 1
if max_frames and len(indices) >= max_frames:
break
frames, times = [], []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok:
break
frames.append(_Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)))
times.append(idx / fps)
cap.release()
if not frames:
raise ValueError(f"No frames decoded from control video {path}")
return VideoRef(frames=frames, timestamps=times)
@torch.no_grad()
def encode_minimax_h3_prompt(
text_encoder, # transformers Qwen3VLForConditionalGeneration
@ -33,7 +82,9 @@ def encode_minimax_h3_prompt(
keyframes: Optional[List] = None, # PIL images already on the target canvas
device: Optional[torch.device] = None,
dtype: Optional[torch.dtype] = None,
max_length: Optional[int] = None, # cap on PROMPT tokens (vision blocks are never cut)
max_length: Optional[
int
] = None, # cap on PROMPT tokens (vision blocks are never cut)
):
"""Encode ONE prompt (with optional keyframes) into MiniMax-H3 conditioning.
@ -53,24 +104,68 @@ def encode_minimax_h3_prompt(
device = text_encoder.device
pixel_values, image_grid_thw = None, None
pixel_values_videos, video_grid_thw = None, None
token_ids: List[int] = []
token_tags: List[int] = []
if keyframes:
vision = processor.image_processor(images=keyframes, return_tensors="pt")
pixel_values = vision["pixel_values"]
image_grid_thw = vision["image_grid_thw"]
images = [k for k in keyframes if not isinstance(k, VideoRef)]
videos = [k for k in keyframes if isinstance(k, VideoRef)]
merge = processor.image_processor.merge_size**2
vision_start = tokenizer.convert_tokens_to_ids("<|vision_start|>")
vision_end = tokenizer.convert_tokens_to_ids("<|vision_end|>")
image_pad = tokenizer.convert_tokens_to_ids("<|image_pad|>")
for i in range(len(keyframes)):
num_image_tokens = int(image_grid_thw[i].prod()) // merge
label_ids = tokenizer(f"<Picture {i + 1}>: ", add_special_tokens=False)[
"input_ids"
]
vision_ids = [vision_start] + [image_pad] * num_image_tokens + [vision_end]
token_ids += label_ids + vision_ids
token_tags += [TEXT_TAG] * len(label_ids) + [VIDEO_TAG] * len(vision_ids)
video_pad = tokenizer.convert_tokens_to_ids("<|video_pad|>")
if images:
vision = processor.image_processor(images=images, return_tensors="pt")
pixel_values = vision["pixel_values"]
image_grid_thw = vision["image_grid_thw"]
if videos:
vids = processor.video_processor(
videos=[v.frames for v in videos], return_tensors="pt"
)
pixel_values_videos = vids["pixel_values_videos"]
video_grid_thw = vids["video_grid_thw"]
pic_idx, vid_idx = 0, 0
for k in keyframes:
if isinstance(k, VideoRef):
grid = video_grid_thw[vid_idx]
per_pair = int(grid[1] * grid[2]) // merge
label_ids = tokenizer(
f"<Video {vid_idx + 1}>: ", add_special_tokens=False
)["input_ids"]
token_ids += label_ids
token_tags += [TEXT_TAG] * len(label_ids)
# one timestamped vision block per merged frame PAIR: the
# video processor merges temporal_patch_size=2 frames, repeat-
# padding an odd count; the timestamp is the pair's mean time
times = list(k.timestamps)
if len(times) % 2 == 1:
times.append(times[-1])
for t_pair in range(int(grid[0])):
mean_t = (times[2 * t_pair] + times[2 * t_pair + 1]) / 2.0
ts_ids = tokenizer(
f"<{round(mean_t, 1):.1f} seconds>", add_special_tokens=False
)["input_ids"]
vision_ids = [vision_start] + [video_pad] * per_pair + [vision_end]
token_ids += ts_ids + vision_ids
token_tags += [TEXT_TAG] * len(ts_ids) + [VIDEO_TAG] * len(
vision_ids
)
vid_idx += 1
else:
num_image_tokens = int(image_grid_thw[pic_idx].prod()) // merge
label_ids = tokenizer(
f"<Picture {pic_idx + 1}>: ", add_special_tokens=False
)["input_ids"]
vision_ids = (
[vision_start] + [image_pad] * num_image_tokens + [vision_end]
)
token_ids += label_ids + vision_ids
token_tags += [TEXT_TAG] * len(label_ids) + [VIDEO_TAG] * len(
vision_ids
)
pic_idx += 1
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
if max_length is not None and max_length > 0:
@ -101,6 +196,10 @@ def encode_minimax_h3_prompt(
if pixel_values is None
else pixel_values.to(device, text_encoder.dtype),
image_grid_thw=None if image_grid_thw is None else image_grid_thw.to(device),
pixel_values_videos=None
if pixel_values_videos is None
else pixel_values_videos.to(device, text_encoder.dtype),
video_grid_thw=None if video_grid_thw is None else video_grid_thw.to(device),
use_cache=False,
output_hidden_states=True,
)

View File

@ -173,9 +173,18 @@ class SDTrainer(BaseSDTrainProcess):
# see if we need to encode the control images
if self.sd.encode_control_in_text_embeddings and has_control_images:
video_exts = ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']
def _is_ctrl_video(pth):
return os.path.splitext(str(pth))[1].lower() in video_exts
ctrl_img_list = []
if gen_img_config.ctrl_img is not None:
if gen_img_config.ctrl_img is not None and _is_ctrl_video(gen_img_config.ctrl_img):
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_img_config.ctrl_img))
elif gen_img_config.ctrl_img is not None:
ctrl_img = Image.open(gen_img_config.ctrl_img).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img = (
@ -185,7 +194,11 @@ class SDTrainer(BaseSDTrainProcess):
)
ctrl_img_list.append(ctrl_img)
if gen_img_config.ctrl_img_1 is not None:
if gen_img_config.ctrl_img_1 is not None and _is_ctrl_video(gen_img_config.ctrl_img_1):
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_img_config.ctrl_img_1))
elif gen_img_config.ctrl_img_1 is not None:
ctrl_img_1 = Image.open(gen_img_config.ctrl_img_1).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_1 = (
@ -194,7 +207,11 @@ class SDTrainer(BaseSDTrainProcess):
.to(self.sd.device_torch, dtype=self.sd.torch_dtype)
)
ctrl_img_list.append(ctrl_img_1)
if gen_img_config.ctrl_img_2 is not None:
if gen_img_config.ctrl_img_2 is not None and _is_ctrl_video(gen_img_config.ctrl_img_2):
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_img_config.ctrl_img_2))
elif gen_img_config.ctrl_img_2 is not None:
ctrl_img_2 = Image.open(gen_img_config.ctrl_img_2).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_2 = (
@ -203,7 +220,11 @@ class SDTrainer(BaseSDTrainProcess):
.to(self.sd.device_torch, dtype=self.sd.torch_dtype)
)
ctrl_img_list.append(ctrl_img_2)
if gen_img_config.ctrl_img_3 is not None:
if gen_img_config.ctrl_img_3 is not None and _is_ctrl_video(gen_img_config.ctrl_img_3):
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_img_config.ctrl_img_3))
elif gen_img_config.ctrl_img_3 is not None:
ctrl_img_3 = Image.open(gen_img_config.ctrl_img_3).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_3 = (

View File

@ -238,6 +238,14 @@ class DataLoaderBatchDTO:
self.audio_tensor: Union[torch.Tensor, None] = None
self.first_frame_latents: Union[torch.Tensor, None] = None
self.audio_latents: Union[torch.Tensor, None] = None
# control-video reference paths (encoded + disk-cached lazily by
# models with supports_video_control_images)
self.control_video_paths_list: Union[List, None] = None
if any(getattr(x, 'control_video_paths', None) for x in self.file_items):
self.control_video_paths_list = [
list(getattr(x, 'control_video_paths', None) or [])
for x in self.file_items
]
# just for holding noise and preds during training
self.audio_target: Union[torch.Tensor, None] = None

View File

@ -69,6 +69,7 @@ transforms_dict = {
}
img_ext_list = ['.jpg', '.jpeg', '.png', '.webp']
video_ext_list = ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']
def standardize_images(images):
@ -1094,12 +1095,25 @@ class ControlFileItemDTOMixin:
file_name_no_ext = os.path.splitext(os.path.basename(img_path))[0]
found_control_images = []
found_control_videos = []
allow_video_controls = sd is not None and getattr(
sd, 'supports_video_control_images', False)
for control_path in control_path_list:
for ext in img_ext_list:
if os.path.exists(os.path.join(control_path, file_name_no_ext + ext)):
found_control_images.append(os.path.join(control_path, file_name_no_ext + ext))
self.has_control_image = True
break
else:
if allow_video_controls:
for ext in video_ext_list:
if os.path.exists(os.path.join(control_path, file_name_no_ext + ext)):
found_control_videos.append(os.path.join(control_path, file_name_no_ext + ext))
self.has_control_image = True
break
# control VIDEO paths ride on the item; the model encodes and
# disk-caches them on first use (see minimax_h3 ref2va)
self.control_video_paths = found_control_videos or None
self.control_path = found_control_images
if len(self.control_path) == 0:
self.control_path = None
@ -1134,6 +1148,9 @@ class ControlFileItemDTOMixin:
control_path_list = self.get_new_control_paths()
if not isinstance(control_path_list, list):
control_path_list = [control_path_list]
# video-only controls leave control_path as None (their latents come
# from the ref-video cache, not this image loader)
control_path_list = [p for p in control_path_list if p is not None]
for control_path in control_path_list:
try:
@ -2117,6 +2134,8 @@ class TextEmbeddingFileItemDTOMixin:
# if we have a control image, cache the path
if self.encode_control_in_text_embeddings and self.control_path is not None:
item["control_path"] = self.control_path
if self.encode_control_in_text_embeddings and getattr(self, 'control_video_paths', None):
item["control_videos"] = sorted(self.control_video_paths)
# first-frame vision conditioning changes the embedding content -> new cache key
elif (
getattr(self, "encode_first_frame_in_text_embeddings", False)

View File

@ -177,6 +177,9 @@ class BaseModel:
# set true for models that encode control image into text embeddings
self.encode_control_in_text_embeddings = False
# control files may be VIDEOS (cached like dataset items, exposed on
# the batch as control_video_latents_list); see minimax_h3 ref2va
self.supports_video_control_images = False
# control images will come in as a list for encoding some things if true
self.has_multiple_control_images = False
# do not resize control images
@ -543,7 +546,11 @@ class BaseModel:
if has_control_images and self.encode_control_in_text_embeddings:
ctrl_img_list = []
if gen_config.ctrl_img is not None:
if gen_config.ctrl_img is not None and os.path.splitext(str(gen_config.ctrl_img))[1].lower() in ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']:
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_config.ctrl_img))
elif gen_config.ctrl_img is not None:
ctrl_img = Image.open(gen_config.ctrl_img).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img = (
@ -553,7 +560,11 @@ class BaseModel:
)
ctrl_img_list.append(ctrl_img)
if gen_config.ctrl_img_1 is not None:
if gen_config.ctrl_img_1 is not None and os.path.splitext(str(gen_config.ctrl_img_1))[1].lower() in ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']:
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_config.ctrl_img_1))
elif gen_config.ctrl_img_1 is not None:
ctrl_img_1 = Image.open(gen_config.ctrl_img_1).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_1 = (
@ -562,7 +573,11 @@ class BaseModel:
.to(self.device_torch, dtype=self.torch_dtype)
)
ctrl_img_list.append(ctrl_img_1)
if gen_config.ctrl_img_2 is not None:
if gen_config.ctrl_img_2 is not None and os.path.splitext(str(gen_config.ctrl_img_2))[1].lower() in ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']:
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_config.ctrl_img_2))
elif gen_config.ctrl_img_2 is not None:
ctrl_img_2 = Image.open(gen_config.ctrl_img_2).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_2 = (
@ -571,7 +586,11 @@ class BaseModel:
.to(self.device_torch, dtype=self.torch_dtype)
)
ctrl_img_list.append(ctrl_img_2)
if gen_config.ctrl_img_3 is not None:
if gen_config.ctrl_img_3 is not None and os.path.splitext(str(gen_config.ctrl_img_3))[1].lower() in ['.mp4', '.avi', '.mov', '.webm', '.mkv', '.wmv', '.m4v', '.flv']:
# control VIDEO: pass the path through; models with
# supports_video_control_images handle it in get_prompt_embeds
ctrl_img_list.append(str(gen_config.ctrl_img_3))
elif gen_config.ctrl_img_3 is not None:
ctrl_img_3 = Image.open(gen_config.ctrl_img_3).convert("RGB")
# convert to 0 to 1 tensor
ctrl_img_3 = (

View File

@ -7,6 +7,9 @@ import { FaUpload, FaImage, FaTimes } from 'react-icons/fa';
import { apiClient } from '@/utils/api';
import type { AxiosProgressEvent } from 'axios';
const VIDEO_EXTS = ['.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.wmv', '.flv'];
const isVideoPath = (p: string) => VIDEO_EXTS.some(ext => p.toLowerCase().endsWith(ext));
interface Props {
src: string | null | undefined;
className?: string;
@ -27,7 +30,8 @@ export default function SampleControlImage({
const backgroundUrl = useMemo(() => {
if (localPreview) return localPreview;
if (src) return `/api/img/${encodeURIComponent(src)}`;
// videos preview as a server-generated thumbnail
if (src) return `/api/img/${encodeURIComponent(src)}${isVideoPath(src) ? '?thumb=1' : ''}`;
return null;
}, [src, localPreview]);
@ -37,8 +41,10 @@ export default function SampleControlImage({
setIsUploading(true);
setUploadProgress(0);
const objectUrl = URL.createObjectURL(file);
setLocalPreview(objectUrl);
// an object URL only works as a background preview for images; video
// previews come from the server thumbnail after the upload lands
const objectUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
if (objectUrl) setLocalPreview(objectUrl);
const formData = new FormData();
formData.append('files', file);
@ -62,7 +68,7 @@ export default function SampleControlImage({
} finally {
setIsUploading(false);
setUploadProgress(0);
URL.revokeObjectURL(objectUrl);
if (objectUrl) URL.revokeObjectURL(objectUrl);
if (fileInputRef.current) fileInputRef.current.value = '';
}
},
@ -94,7 +100,10 @@ export default function SampleControlImage({
// Drag & drop only; click handled via our own hidden input
const { getRootProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'] },
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
'video/*': VIDEO_EXTS,
},
multiple: false,
noClick: true,
noKeyboard: true,
@ -128,7 +137,7 @@ export default function SampleControlImage({
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/*,video/*"
className="hidden"
onChange={e => {
const file = e.currentTarget.files?.[0];