388 lines
16 KiB
Python
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",
|
|
]
|