Files

388 lines
16 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""MiniMax H3 reference inputs and media preparation helpers."""
from __future__ import annotations
from dataclasses import dataclass, field
import math
from numbers import Integral, Real
import os
from typing import Any, Literal
import numpy as np
from PIL import Image, ImageOps
import torch
from fastvideo.models.vision_utils import load_image
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_AUDIO_CHANNELS,
MINIMAX_H3_CANVAS_MULTIPLE,
MINIMAX_H3_FPS,
MINIMAX_H3_FRAMES_PER_CHUNK,
MINIMAX_H3_LATENTS_PER_CHUNK,
MINIMAX_H3_MAX_DURATION,
resolve_canvas_size,
)
MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE = 2048
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS = 2.0
MINIMAX_H3_QWEN_TEMPORAL_PATCH = 2
MINIMAX_H3_MAX_REFERENCE_IMAGES = 9
MINIMAX_H3_MAX_REFERENCE_VIDEOS = 3
MINIMAX_H3_MAX_REFERENCE_AUDIOS = 3
MINIMAX_H3_MAX_REFERENCES = 12
@dataclass(frozen=True)
class MiniMaxH3Reference:
"""One ordered Ref2VA input; decoding is deferred until request preparation."""
source: Any
media_type: Literal["image", "video", "audio"] = "image"
soundtrack: Any | None = None
fps: float | None = None
sample_rate: int | None = None
def __post_init__(self) -> None:
if self.source is None:
raise ValueError("A MiniMax-H3 reference requires a media source.")
if self.media_type not in ("image", "video", "audio"):
raise ValueError(f"Unsupported MiniMax-H3 reference type: {self.media_type!r}.")
if self.soundtrack is not None and self.media_type != "video":
raise ValueError("Only a video reference may carry a separate soundtrack.")
if self.fps is not None and (self.media_type != "video" or isinstance(self.fps, bool)
or not isinstance(self.fps, Real) or self.fps <= 0):
raise ValueError("Reference `fps` must be positive and is only valid for video.")
if self.sample_rate is not None and (self.media_type == "image" or isinstance(self.sample_rate, bool)
or not isinstance(self.sample_rate, Integral) or self.sample_rate <= 0):
raise ValueError("Reference `sample_rate` must be positive and is only valid for audio-bearing media.")
@dataclass
class MiniMaxH3PreparedReference:
"""Decoded Ref2VA media and the latent geometry resolved by condition encoding."""
media_type: Literal["image", "video", "audio"]
has_audio: bool = False
image: Image.Image | None = None
frames: np.ndarray | None = None
waveform: torch.Tensor | None = None
block_timestamps: list[float] = field(default_factory=list)
num_latent_frames: int = 1
latent_height: int = 0
latent_width: int = 0
num_audio_latents: int = 0
def validate_references(references: list[Any]) -> list[MiniMaxH3Reference]:
"""Validate the ordered Ref2VA media list and its per-modality limits."""
if not references:
raise ValueError("MiniMax-H3 Ref2VA requires at least one reference.")
if not all(isinstance(reference, MiniMaxH3Reference) for reference in references):
raise TypeError("Every Ref2VA entry must be a MiniMaxH3Reference.")
typed = list(references)
counts = {
media_type: sum(reference.media_type == media_type for reference in typed)
for media_type in ("image", "video", "audio")
}
limits = {
"image": MINIMAX_H3_MAX_REFERENCE_IMAGES,
"video": MINIMAX_H3_MAX_REFERENCE_VIDEOS,
"audio": MINIMAX_H3_MAX_REFERENCE_AUDIOS,
}
for media_type, limit in limits.items():
if counts[media_type] > limit:
raise ValueError(f"MiniMax-H3 accepts at most {limit} {media_type} references.")
if len(typed) > MINIMAX_H3_MAX_REFERENCES:
raise ValueError(f"MiniMax-H3 accepts at most {MINIMAX_H3_MAX_REFERENCES} references.")
if counts["audio"] == len(typed):
raise ValueError("Audio references must be paired with at least one image or video reference.")
return typed
def _import_av() -> Any:
try:
import av
except ImportError as error:
raise ImportError("Decoding MiniMax-H3 video or audio references requires PyAV.") from error
return av
def _decode_audio_stream(av_module: Any, container: Any, stream: Any) -> tuple[torch.Tensor, int]:
sample_rate = int(stream.codec_context.sample_rate)
resampler = av_module.audio.resampler.AudioResampler(format="fltp", layout=stream.layout, rate=sample_rate)
chunks: list[torch.Tensor] = []
for frame in container.decode(stream):
chunks.extend(torch.from_numpy(item.to_ndarray()) for item in resampler.resample(frame))
chunks.extend(torch.from_numpy(item.to_ndarray()) for item in resampler.resample(None))
if not chunks:
raise ValueError("The reference audio stream contains no samples.")
return torch.cat(chunks, dim=-1).to(torch.float32), sample_rate
def decode_reference_audio(source: str | os.PathLike[str]) -> tuple[torch.Tensor, int]:
"""Decode the first audio stream without imposing H3's target sample rate."""
av_module = _import_av()
with av_module.open(str(source)) as container:
if not container.streams.audio:
raise ValueError(f"Reference media {source!s} contains no audio stream.")
return _decode_audio_stream(av_module, container, container.streams.audio[0])
def decode_reference_video(source: str | os.PathLike[str]) -> tuple[np.ndarray, float, tuple[torch.Tensor, int] | None]:
"""Decode RGB frames, display rotation, frame rate, and an optional soundtrack."""
av_module = _import_av()
with av_module.open(str(source)) as container:
if not container.streams.video:
raise ValueError(f"Reference media {source!s} contains no video stream.")
stream = container.streams.video[0]
rate = stream.average_rate or getattr(stream, "guessed_rate", None)
if rate is None:
raise ValueError(f"Reference media {source!s} does not expose a frame rate.")
# everything past the trimmed maximum is discarded downstream; stop
# decoding (and buffering) once that many source frames are in hand
max_frames = math.ceil(MINIMAX_H3_MAX_DURATION * float(rate)) + 1
frames = []
rotation = 0.0
for frame in container.decode(stream):
rotation = float(getattr(frame, "rotation", 0.0) or 0.0)
frames.append(frame.to_ndarray(format="rgb24"))
if len(frames) >= max_frames:
break
if not frames:
raise ValueError(f"Reference media {source!s} contains no video frames.")
soundtrack = None
if container.streams.audio:
container.seek(0)
soundtrack = _decode_audio_stream(av_module, container, container.streams.audio[0])
result = np.stack(frames)
turns = round(rotation / 90.0) % 4
if turns:
result = np.ascontiguousarray(np.rot90(result, k=-turns, axes=(1, 2)))
return result, float(rate), soundtrack
def resolve_reference_image_size(width: int, height: int) -> tuple[int, int]:
"""Resolve the released 2048-short-edge reference-image canvas."""
if width <= 0 or height <= 0:
raise ValueError(f"A reference image must have a positive size, got {width}x{height}.")
if width > 4 * height or height > 4 * width:
raise ValueError(f"A reference image must be within 1:4 and 4:1, got {width}x{height}.")
scale = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
multiple = MINIMAX_H3_CANVAS_MULTIPLE
return (
max(multiple,
round(height * scale / multiple) * multiple),
max(multiple,
round(width * scale / multiple) * multiple),
)
def reference_media_to_uint8(media: Any) -> np.ndarray:
"""Normalize PIL, tensor, NumPy, or frame-list media to channel-last uint8."""
if isinstance(media, list):
if not media:
raise ValueError("A reference video must contain at least one frame.")
return np.stack([reference_media_to_uint8(item) for item in media])
if isinstance(media, Image.Image):
return np.asarray(media.convert("RGB"))
if isinstance(media, torch.Tensor):
media = media.movedim(-3, -1).detach().cpu().numpy()
media = np.asarray(media)
if media.dtype != np.uint8:
media = (media * 255.0).round().clip(0, 255).astype(np.uint8)
return media
def prepare_reference_image(image: Image.Image, height: int, width: int) -> Image.Image:
"""Resize an RGB reference image to its resolved condition canvas."""
return image if image.size == (width, height) else image.resize((width, height), Image.Resampling.LANCZOS)
def resample_reference_frames(frames: np.ndarray, fps: float) -> np.ndarray:
"""Nearest-resample reference frames onto H3's fixed 24-fps timeline."""
if frames.ndim != 4 or frames.shape[-1] != 3 or frames.shape[0] == 0:
raise ValueError(f"A reference video must be non-empty RGB frames, got {tuple(frames.shape)}.")
if fps <= 0:
raise ValueError(f"A reference video must have a positive frame rate, got {fps}.")
if fps == MINIMAX_H3_FPS:
return frames
scale = MINIMAX_H3_FPS / fps
slots = np.floor(np.arange(frames.shape[0]) * scale + 0.5).astype(np.int64)
repeats = np.diff(slots, append=math.floor(frames.shape[0] * scale + 0.5))
return np.repeat(frames, repeats, axis=0)
def prepare_reference_frames(frames: np.ndarray, num_frames: int) -> np.ndarray:
"""Trim a reference video to the request and resize every RGB frame."""
if frames.ndim != 4 or frames.shape[-1] != 3 or frames.shape[0] == 0:
raise ValueError(f"A reference video must be non-empty RGB frames, got {tuple(frames.shape)}.")
frames = frames[:num_frames]
height, width = resolve_canvas_size(frames.shape[2], frames.shape[1])
if frames.shape[1:3] == (height, width):
return frames
return np.stack(
[np.asarray(Image.fromarray(frame).resize((width, height), Image.Resampling.LANCZOS)) for frame in frames])
def sample_reference_video_frames(frames: np.ndarray) -> tuple[list[np.ndarray], list[float]]:
"""Sample the released 2-fps Qwen presentation and its block timestamps."""
if frames.ndim != 4 or frames.shape[0] == 0:
raise ValueError("A prepared reference video must contain frames.")
stride = MINIMAX_H3_FPS / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS
indices: list[int] = []
cursor = 0.0
while round(cursor) < frames.shape[0]:
if not indices or round(cursor) > indices[-1]:
indices.append(round(cursor))
cursor += stride
timestamps = [index / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS for index in range(len(indices))]
timestamps += [timestamps[-1]] * (-len(timestamps) % MINIMAX_H3_QWEN_TEMPORAL_PATCH)
block_timestamps = [(timestamps[index] + timestamps[index + MINIMAX_H3_QWEN_TEMPORAL_PATCH - 1]) / 2
for index in range(0, len(timestamps), MINIMAX_H3_QWEN_TEMPORAL_PATCH)]
return [frames[index] for index in indices], block_timestamps
def prepare_reference_waveform(
waveform: torch.Tensor,
sample_rate: int,
target_sample_rate: int,
max_duration: float,
) -> torch.Tensor:
"""Normalize reference audio to stereo float32 at the audio-VAE rate."""
waveform = torch.as_tensor(waveform).detach().cpu()
if waveform.ndim != 2 or waveform.shape[0] not in (1, MINIMAX_H3_AUDIO_CHANNELS):
raise ValueError(
f"A reference soundtrack must be mono or stereo [channels, samples], got {tuple(waveform.shape)}.")
if waveform.shape[-1] == 0:
raise ValueError("A reference soundtrack must contain samples.")
if sample_rate <= 0 or target_sample_rate <= 0:
raise ValueError("Reference audio sample rates must be positive.")
waveform = waveform.to(torch.float32)[:, :int(max_duration * sample_rate)]
if waveform.shape[0] == 1:
waveform = waveform.expand(MINIMAX_H3_AUDIO_CHANNELS, -1).contiguous()
if sample_rate == target_sample_rate:
return waveform
try:
import torchaudio
except (ImportError, OSError):
from math import gcd
from scipy.signal import resample_poly
divisor = gcd(sample_rate, target_sample_rate)
resampled = resample_poly(
waveform.numpy(),
up=target_sample_rate // divisor,
down=sample_rate // divisor,
axis=-1,
)
return torch.from_numpy(np.asarray(resampled, dtype=np.float32))
return torchaudio.transforms.Resample(sample_rate, target_sample_rate)(waveform)
def trim_reference_num_frames(num_frames: int) -> int:
"""Trim video references to the causal VAE's complete chunk geometry."""
if num_frames < 1:
raise ValueError(f"A reference video must have at least one frame, got {num_frames}.")
return (
max(1,
(num_frames - MINIMAX_H3_LATENTS_PER_CHUNK) // MINIMAX_H3_FRAMES_PER_CHUNK) * MINIMAX_H3_FRAMES_PER_CHUNK +
MINIMAX_H3_LATENTS_PER_CHUNK)
def _resolve_audio_source(source: Any) -> tuple[torch.Tensor, int | None]:
if isinstance(source, str | os.PathLike):
return decode_reference_audio(source)
return torch.as_tensor(source), None
def prepare_reference(
reference: MiniMaxH3Reference,
num_frames: int,
target_sample_rate: int,
) -> MiniMaxH3PreparedReference:
"""Decode and normalize one deferred Ref2VA medium."""
prepared = MiniMaxH3PreparedReference(media_type=reference.media_type)
if reference.media_type == "image":
source = reference.source
image = load_image(str(source)) if isinstance(source, str | os.PathLike) else source
if not isinstance(image, Image.Image):
pixels = reference_media_to_uint8(image)
if pixels.ndim != 3 or pixels.shape[-1] != 3:
raise ValueError(f"An image reference must be RGB, got {tuple(pixels.shape)}.")
image = Image.fromarray(pixels)
image = ImageOps.exif_transpose(image).convert("RGB")
height, width = resolve_reference_image_size(*image.size)
prepared.image = prepare_reference_image(image, height, width)
return prepared
if reference.media_type == "video":
decoded_soundtrack = None
decoded_sample_rate = None
if isinstance(reference.source, str | os.PathLike):
frames, decoded_fps, soundtrack = decode_reference_video(reference.source)
if soundtrack is not None:
decoded_soundtrack, decoded_sample_rate = soundtrack
else:
frames = reference_media_to_uint8(reference.source)
decoded_fps = float(MINIMAX_H3_FPS)
fps = float(reference.fps if reference.fps is not None else decoded_fps)
prepared.frames = prepare_reference_frames(resample_reference_frames(frames, fps), num_frames)
if reference.soundtrack is not None:
waveform, source_rate = _resolve_audio_source(reference.soundtrack)
decoded_soundtrack = waveform
decoded_sample_rate = source_rate
if decoded_soundtrack is not None:
sample_rate = reference.sample_rate or decoded_sample_rate or target_sample_rate
prepared.waveform = prepare_reference_waveform(
decoded_soundtrack,
int(sample_rate),
target_sample_rate,
max_duration=num_frames / MINIMAX_H3_FPS,
)
prepared.has_audio = True
elif reference.sample_rate is not None:
raise ValueError("A silent video reference cannot specify `sample_rate`.")
return prepared
waveform, decoded_sample_rate = _resolve_audio_source(reference.source)
sample_rate = reference.sample_rate or decoded_sample_rate or target_sample_rate
prepared.waveform = prepare_reference_waveform(
waveform,
int(sample_rate),
target_sample_rate,
max_duration=num_frames / MINIMAX_H3_FPS,
)
prepared.has_audio = True
return prepared
__all__ = [
"MINIMAX_H3_MAX_REFERENCES",
"MINIMAX_H3_MAX_REFERENCE_AUDIOS",
"MINIMAX_H3_MAX_REFERENCE_IMAGES",
"MINIMAX_H3_MAX_REFERENCE_VIDEOS",
"MINIMAX_H3_QWEN_TEMPORAL_PATCH",
"MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS",
"MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE",
"MiniMaxH3PreparedReference",
"MiniMaxH3Reference",
"decode_reference_audio",
"decode_reference_video",
"prepare_reference",
"prepare_reference_frames",
"prepare_reference_image",
"prepare_reference_waveform",
"reference_media_to_uint8",
"resample_reference_frames",
"resolve_reference_image_size",
"sample_reference_video_frames",
"trim_reference_num_frames",
"validate_references",
]