[feat]: add Pika H3 Flex inference support

Add Pika H3 Flex (MiniMax-H3 plus the released fl2va/ref2va rank-128 adapters) as a FastVideo model family: registry/config/presets, bucket geometry with tiered reference sizing, automatic adapter selection and manifest validation, the fixed six-forward re-noise sampler, T2VA/I2VA/L2VA/FL2VA/Ref2VA pipeline support, an example, docs, and CPU unit tests.

GPU end-to-end validation is still pending. This change does not add PDD, VSA, or new kernel paths.
This commit is contained in:
aryan5v
2026-10-29 17:37:52 -07:00
committed by Aryan Kumar
parent 7203b4976e
commit dcbaed504f
23 changed files with 1769 additions and 40 deletions
+100
View File
@@ -0,0 +1,100 @@
# Pika H3 Flex
Pika H3 Flex is a post-trained MiniMax-H3 model that keeps H3's packed
video/audio transformer, Qwen3-VL conditioner, and video/audio VAEs. Pika ships
two rank-128 PEFT adapters instead of a second base checkpoint:
| Adapter | Trunk | Tasks |
|---|---|---|
| `fl2va/adapter.safetensors` | `transformer` | T2VA, I2VA, L2VA, FL2VA |
| `ref2va/adapter.safetensors` | `transformer_ref` | Ref2VA |
FastVideo detects `Pika-Labs/Pika-H3-Flex`, downloads only the selected task's
adapter, and applies it as an ordinary LoRA. The released adapter is selected
automatically; pass `lora_path` only when overriding it with a compatible
Pika adapter.
## Sampling contract
Pika's sampler is fixed, not a tunable step count:
* 6 transformer forwards over clean-time rungs
`1000, 750, 500, 250, 100, 40, 0`;
* video shift `12.0`, audio shift `3.0`;
* no CFG, `guidance_scale=1.0`, empty negative prompt;
* each step re-noises the predicted clean sample with fresh Gaussian noise from
a generator seeded `seed + 1`.
`num_inference_steps` must therefore be `6`; other values are rejected. This is
not a PDD checkpoint and it has no trained VSA gates, so PDD fused blocks and
VSA sparsity are not used.
## Tasks
```bash
# text to video + audio
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--prompt "a paper boat drifting down a rainy street"
# first frame, last frame, or both
python examples/inference/basic/basic_pika_h3_flex.py --task i2va \
--image first.png --prompt "the camera slowly pushes in"
python examples/inference/basic/basic_pika_h3_flex.py --task fl2va \
--image first.png --last-image last.png --prompt "the flower opens"
# ordered references, including reference video and audio
python examples/inference/basic/basic_pika_h3_flex.py --task ref2va \
--reference image:person.png --reference video:dance.mp4 \
--reference audio:voice.wav --prompt "the person dances to the music"
```
Ref2VA accepts up to 12 references: 9 images, 3 videos, and 3 audio. Each video
and audio reference is 2–15 s; video/audio totals are capped at 15 s per
modality. A video reference needs at least 22 decoded frames at 24 fps. At
least one image or video is required, and the Ref2VA pipeline must be selected
with `PikaH3FlexRef2VAModularPipeline` (the example does this).
## Canvas and frames
Pika uses an exact 32-pixel bucket lattice. A request that matches a bucket is
kept exactly; otherwise FastVideo picks the tier from the requested short edge
(`<= 480` → p480, else p768) and snaps to the nearest aspect bucket in that tier.
| Tier | Short edge | Max pixels | Reference-image short edge |
|---|---|---|---|
| p480 | 480 | 832×480 | 1024 |
| p768 | 768 | 1344×768 | 2048 |
Frame counts follow Pika's `17n + 5` grid at 24 fps (5–15 s, 120–362 aligned
frames). The default 16:9 p768 canvas is 1344×768 and 124 frames.
Pika center-crops both keyframes to the bucket. MiniMax-H3 stretches its first
frame instead; do not reuse that fit for Pika.
## Training-free acceleration
The 6 forwards already use FastVideo's normal inference path. Available
lossless switches:
* `FASTVIDEO_MINIMAX_H3_EXACT_KERNELS=all` — exact fused H3 kernels;
* `FASTVIDEO_H3_REF2VA_MEMO_ENTRIES=16` — cache reference conditioning
(Ref2VA);
* `FASTVIDEO_H3_VAE_TILE_PARALLEL=1` — parallel VAE tiles;
* `FASTVIDEO_ULYSSES_A2A=auto` — Ulysses all-to-all for multi-GPU sequence
parallelism;
* `OffloadConfig(lazy_module_load=True)` and `dit_layerwise=True` for smaller
GPUs;
* `CompileConfig(enabled=True)` for `torch.compile`;
* the standard FlashAttention/SDPA backend selection.
`--lossless-accel` in the example enables the first four before the generator
starts. Kernel work beyond the existing exact kernels is intentionally not part
of this integration.
## Hardware
The selected H3 trunk plus Qwen3-VL does not fit comfortably on a 24 GB card
without offload. A single 80 GB GPU can run with text-encoder offload; use
layerwise DiT offload or multiple GPUs for smaller devices. Ref2VA encodes
reference images at up to a 2048 short edge, so its conditioning memory is
higher than T2VA's.
+1
View File
@@ -68,6 +68,7 @@ column links a runnable script in `examples/inference/basic/` where one exists.
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br />`mignonjia/mg_longtuning_distilled_zelda`<br />`mignonjia/mg_sf_distilled_zelda_1k_steps`<br />`mignonjia/mg_sf_distilled_zelda`<br />`mignonjia/mg_causal_zelda`<br />`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br />[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br />[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
| pika_h3_flex | `Pika-Labs/Pika-H3-Flex` | T2V, I2V | [T2VA/I2VA/L2VA/FL2VA/Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pika_h3_flex.py) |
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
| qwen_image21 | `Qwen/Qwen-Image-2.1` | T2I, I2I | [basic_qwen_image21.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_qwen_image21.py) |
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
@@ -0,0 +1,180 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate synchronized video/audio with Pika H3 Flex.
Five task shapes share one model release:
``t2va`` text only
``i2va`` first image
``l2va`` last image
``fl2va`` first and last images
``ref2va`` ordered image/video/audio references
The six-forward ladder is part of the adapter contract; this script deliberately
does not expose a step-count flag. ``--lossless-accel`` turns on the existing
training-free FastVideo switches (bit-identical kernels, reference memo,
parallel VAE tiles) before the generator starts. Kernel/VSA work is separate.
Examples:
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--prompt "a paper boat drifting down a rainy street"
python examples/inference/basic/basic_pika_h3_flex.py --task fl2va \
--image first.png --last-image last.png --prompt "the flower opens"
python examples/inference/basic/basic_pika_h3_flex.py --task ref2va \
--reference image:person.png --reference video:dance.mp4 \
--reference audio:voice.wav --prompt "the person dances to the music"
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from typing import Any
from fastvideo import VideoGenerator
from fastvideo.api import (
CompileConfig,
ComponentConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
InputConfig,
OffloadConfig,
OutputConfig,
ParallelismConfig,
PipelineSelection,
SamplingConfig,
)
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
# Training-free switches FastVideo already exposes. Workers inherit the
# environment, so they must be set before VideoGenerator starts.
LOSSLESS_ACCEL_ENV = {
"FASTVIDEO_MINIMAX_H3_EXACT_KERNELS": "all",
"FASTVIDEO_H3_REF2VA_MEMO_ENTRIES": "16",
"FASTVIDEO_H3_VAE_TILE_PARALLEL": "1",
"FASTVIDEO_ULYSSES_A2A": "auto",
}
TASKS = ("t2va", "i2va", "l2va", "fl2va", "ref2va")
def _reference(value: str) -> MiniMaxH3Reference:
try:
media_type, source = value.split(":", 1)
except ValueError as error:
raise argparse.ArgumentTypeError("A reference must be image:PATH, video:PATH, or audio:PATH.") from error
if media_type not in ("image", "video", "audio"):
raise argparse.ArgumentTypeError(f"Unknown reference type {media_type!r}.")
return MiniMaxH3Reference(source=source, media_type=media_type)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-path", default="Pika-Labs/Pika-H3-Flex")
parser.add_argument("--task", choices=TASKS, default="t2va")
parser.add_argument("--prompt", required=True)
parser.add_argument("--image", help="first frame (i2va/fl2va)")
parser.add_argument("--last-image", help="last frame (l2va/fl2va)")
parser.add_argument("--reference",
action="append",
type=_reference,
default=[],
help="ordered reference (repeatable; ref2va)")
parser.add_argument("--height", type=int, default=768)
parser.add_argument("--width", type=int, default=1344)
parser.add_argument("--num-frames", type=int, default=124)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--num-gpus", type=int, default=1)
parser.add_argument("--compile", action="store_true", help="torch.compile the DiT")
parser.add_argument("--lossless-accel", action="store_true", help="enable bit-identical speed-ups")
parser.add_argument("--low-vram", action="store_true", help="stream the DiT layerwise")
parser.add_argument("--output", default="outputs/pika_h3_flex")
args = parser.parse_args()
if args.task in ("i2va", "fl2va") and not args.image:
parser.error(f"--task {args.task} needs --image")
if args.task in ("l2va", "fl2va") and not args.last_image:
parser.error(f"--task {args.task} needs --last-image")
if args.task == "ref2va" and not args.reference:
parser.error("--task ref2va needs at least one --reference")
if args.task != "ref2va" and args.reference:
parser.error("--reference is only valid with --task ref2va")
return args
def _inputs(args: argparse.Namespace) -> InputConfig:
if args.task == "ref2va":
return InputConfig(references=args.reference)
return InputConfig(image_path=args.image, last_image=args.last_image)
def _pipeline(args: argparse.Namespace) -> PipelineSelection:
components = ComponentConfig()
if args.task == "ref2va":
components.override_pipeline_cls_name = "PikaH3FlexRef2VAModularPipeline"
return PipelineSelection(
workload_type="t2v" if args.task == "t2va" else "i2v",
preset=f"pika_h3_flex_{args.task}",
components=components,
vae_tiling=True,
)
def main() -> None:
args = parse_args()
if args.lossless_accel:
for key, value in LOSSLESS_ACCEL_ENV.items():
os.environ.setdefault(key, value)
output_path = Path(args.output) / f"pika_h3_flex_{args.task}.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
offload = OffloadConfig(
dit=False,
dit_layerwise=args.low_vram,
text_encoder=True,
vae=True,
pin_cpu_memory=not args.low_vram,
lazy_module_load=True,
)
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=args.model_path,
engine=EngineConfig(
num_gpus=args.num_gpus,
use_fsdp_inference=args.num_gpus > 1,
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
offload=offload,
compile=CompileConfig(enabled=args.compile),
),
pipeline=_pipeline(args),
))
try:
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
negative_prompt="",
inputs=_inputs(args),
sampling=SamplingConfig(
height=args.height,
width=args.width,
num_frames=args.num_frames,
fps=24,
num_inference_steps=6,
guidance_scale=1.0,
batch_cfg=False,
seed=args.seed,
),
output=OutputConfig(
output_path=str(output_path),
save_video=True,
return_frames=False,
),
))
print(f"Output written to: {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: Apache-2.0
"""Sampling defaults for Pika H3 Flex."""
from dataclasses import dataclass
from fastvideo.api.sampling_param import SamplingParam
@dataclass
class PikaH3FlexSamplingParam(SamplingParam):
"""Six re-noise forwards, no guidance, and the 768p bucket by default."""
num_inference_steps: int = 6
guidance_scale: float = 1.0
batch_cfg: bool = False
negative_prompt: str = ""
height: int = 768
width: int = 1344
num_frames: int = 124
fps: int = 24
seed: int = 0
__all__ = ["PikaH3FlexSamplingParam"]
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex transformer configuration.
The released Pika adapters do not change MiniMax-H3's transformer: the
checkpoint keeps the same packed video/audio trunk, Qwen3-VL conditioner, and
VAEs, and applies a rank-128 PEFT adapter at inference. This subclass exists so
the Pika pipeline names its own component config without duplicating the H3
architecture contract.
"""
from dataclasses import dataclass
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
@dataclass
class PikaH3FlexDiTConfig(MiniMaxH3Config):
"""The released MiniMax-H3 transformer, sampled through Pika's adapters."""
__all__ = ["PikaH3FlexDiTConfig"]
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
"""Pipeline configuration for Pika H3 Flex.
Pika H3 Flex is a post-trained MiniMax-H3: the transformer, Qwen3-VL
conditioner, and video/audio VAEs are the released H3 components, and a rank-128
adapter is merged at load time. What changes is the sampler and the geometry
contract:
* six transformer forwards over the fixed clean-time knots
``1000, 750, 500, 250, 100, 40``, with a fresh-noise re-noise step between
forwards (the terminal ``0`` knot is the output, never a forward);
* video and audio clock shifts of 12 and 3;
* 480p and 768p canvas buckets instead of a free-form canvas;
* center-cropped keyframes and tier-dependent reference-image sizing.
Everything else, including CFG being off and the 24 fps frame grid, is inherited
from MiniMax-H3.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from fastvideo.configs.models.dits.pika_h3_flex import PikaH3FlexDiTConfig
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
if TYPE_CHECKING:
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.api.schema import GenerationRequest
# The released re-noise ladder. The terminal 0 is a sigma-grid point, not a
# transformer forward: six forwards, seven sigmas.
PIKA_DENOISING_STEPS: tuple[int, ...] = (1000, 750, 500, 250, 100, 40)
PIKA_VIDEO_SCHEDULER_SHIFT = 12.0
PIKA_AUDIO_SCHEDULER_SHIFT = 3.0
PIKA_RESOLUTION_TIERS: tuple[str, ...] = ("p480", "p768")
PIKA_DEFAULT_RESOLUTION_TIER = "p768"
@dataclass
class PikaH3FlexPipelineConfig(MiniMaxH3PipelineConfig):
"""MiniMax-H3 components under Pika's adapter, sampler, and bucket contract."""
dit_config: PikaH3FlexDiTConfig = field(default_factory=PikaH3FlexDiTConfig)
# Pika's CLI calls this ``--resolution``; the API has no dedicated tier
# field, so it is a pipeline config knob rather than a per-request one.
pika_resolution_tier: str = PIKA_DEFAULT_RESOLUTION_TIER
pika_denoising_steps: tuple[int, ...] = PIKA_DENOISING_STEPS
def check_pipeline_config(self) -> None:
super().check_pipeline_config()
if self.pika_resolution_tier not in PIKA_RESOLUTION_TIERS:
raise ValueError(f"Pika H3 Flex supports resolution tiers {list(PIKA_RESOLUTION_TIERS)}; got "
f"{self.pika_resolution_tier!r}.")
if tuple(self.pika_denoising_steps) != PIKA_DENOISING_STEPS:
raise ValueError(f"Pika H3 Flex's adapter contract fixes the denoising ladder at "
f"{list(PIKA_DENOISING_STEPS)}; got {list(self.pika_denoising_steps)}.")
if self.dmd_denoising_steps is not None:
raise ValueError("Pika H3 Flex uses its own six-forward re-noise schedule; dmd_denoising_steps must "
"remain unset.")
if self.pdd_step_indices is not None:
raise ValueError("Pika H3 Flex is not a FastH3 PDD student; pdd_step_indices must remain unset.")
def apply_request_constraints(self, request: GenerationRequest, sampling_param: SamplingParam) -> SamplingParam:
"""Fix ``num_inference_steps`` to the adapter's six-forward ladder."""
from fastvideo.api.compat import explicit_request_updates
steps = len(PIKA_DENOISING_STEPS)
requested = explicit_request_updates(request).get("num_inference_steps", steps)
if requested != steps:
raise ValueError(f"Pika H3 Flex runs exactly {steps} transformer forwards; the request sets "
f"num_inference_steps={requested}. The six-step ladder is part of the adapter "
f"contract, not a quality/speed knob.")
sampling_param.num_inference_steps = steps
return sampling_param
__all__ = [
"PIKA_AUDIO_SCHEDULER_SHIFT",
"PIKA_DEFAULT_RESOLUTION_TIER",
"PIKA_DENOISING_STEPS",
"PIKA_RESOLUTION_TIERS",
"PIKA_VIDEO_SCHEDULER_SHIFT",
"PikaH3FlexPipelineConfig",
]
@@ -21,13 +21,31 @@ class MiniMaxH3Scheduler(SchedulerMixin, ConfigMixin):
order = 1
@register_to_config
def __init__(self, shift: float = 12.0) -> None:
def __init__(
self,
shift: float = 12.0,
stochastic_sampling: bool = False,
shift_provided_sigmas: bool = False,
) -> None:
"""H3's rectified-flow scheduler, with Pika's re-noise step as an option.
``stochastic_sampling`` switches the Euler update to the re-noise
contract: draw fresh Gaussian noise at the next sigma and form
``(1 - sigma_next) * x0 + sigma_next * noise``. The terminal sigma
always returns ``x0`` without drawing noise. ``shift_provided_sigmas``
records that explicit sigmas are already shifted (Pika's release uses
it); FastVideo always treats explicit ``set_timesteps(sigmas=...)``
values that way, so it is informational.
"""
if shift <= 0:
raise ValueError(f"`shift` must be positive, got {shift}.")
self.num_inference_steps: int | None = None
self.sigmas: torch.Tensor | None = None
self.timesteps: torch.Tensor | None = None
self._shift = float(shift)
self._stochastic_sampling = bool(stochastic_sampling)
self._shift_provided_sigmas = bool(shift_provided_sigmas)
self._noise_generator: torch.Generator | None = None
self._step_index: int | None = None
self._begin_index: int | None = None
@@ -35,6 +53,18 @@ class MiniMaxH3Scheduler(SchedulerMixin, ConfigMixin):
def shift(self) -> float:
return self._shift
@property
def stochastic_sampling(self) -> bool:
return self._stochastic_sampling
@property
def shift_provided_sigmas(self) -> bool:
return self._shift_provided_sigmas
def set_noise_generator(self, generator: torch.Generator | None) -> None:
"""Set the generator used by stochastic (re-noise) steps."""
self._noise_generator = generator
@property
def step_index(self) -> int | None:
return self._step_index
@@ -127,11 +157,29 @@ class MiniMaxH3Scheduler(SchedulerMixin, ConfigMixin):
denoised = sample + sigma_from_timestep * model_output
compute_dtype = torch.float32 if sample.dtype in (torch.float16, torch.bfloat16) else sample.dtype
sigma = self.sigmas[self._step_index].to(device=sample.device, dtype=compute_dtype)
sigma_next = self.sigmas[self._step_index + 1].to(device=sample.device, dtype=compute_dtype)
ratio = sigma_next / sigma
prev_sample = ratio * sample.to(compute_dtype) + (1.0 - ratio) * denoised.to(compute_dtype)
prev_sample = prev_sample.to(sample.dtype)
if self._stochastic_sampling:
if self._noise_generator is None:
raise ValueError("Stochastic MiniMax-H3 sampling needs a noise generator; call "
"`set_noise_generator` before `step`.")
if float(sigma_next) == 0.0:
# The terminal ladder point is the generated sample. Pika's
# sampler returns x0 here instead of drawing a final noise.
prev_sample = denoised.to(sample.dtype)
else:
noise = torch.randn(
sample.shape,
generator=self._noise_generator,
device=sample.device,
dtype=torch.float32,
)
prev_sample = ((1.0 - sigma_next) * denoised.to(compute_dtype) +
sigma_next * noise).to(sample.dtype)
else:
sigma = self.sigmas[self._step_index].to(device=sample.device, dtype=compute_dtype)
ratio = sigma_next / sigma
prev_sample = ratio * sample.to(compute_dtype) + (1.0 - ratio) * denoised.to(compute_dtype)
prev_sample = prev_sample.to(sample.dtype)
self._step_index += 1
if not return_dict:
@@ -218,6 +218,10 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
pipeline_config_cls: type[MiniMaxH3PipelineConfig] = MiniMaxH3PipelineConfig
_ref2va_default = False
# Family subclasses override these to swap geometry and sampler stages
# without copying the whole stage assembly.
input_preparation_stage_cls: type[MiniMaxH3InputPreparationStage] = MiniMaxH3InputPreparationStage
denoising_stage_cls: type[MiniMaxH3DenoisingStage] = MiniMaxH3DenoisingStage
_required_config_modules = [
"text_encoder",
"tokenizer",
@@ -686,7 +690,7 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
def _add_input_stage(self, fastvideo_args: FastVideoArgs, *, ref2va: bool) -> None:
self.add_stage(
"input_preparation_stage",
MiniMaxH3InputPreparationStage(
self.input_preparation_stage_cls(
vae=self._input_video_geometry(fastvideo_args),
audio_vae=self._input_audio_vae(fastvideo_args, ref2va=ref2va),
ref2va=ref2va,
@@ -734,7 +738,7 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
)
self.add_stage(
"denoising_stage",
MiniMaxH3DenoisingStage(
self.denoising_stage_cls(
transformer=transformer,
scheduler=scheduler,
audio_scheduler=audio_scheduler,
@@ -236,20 +236,33 @@ def build_packed_batch_sequence(documents: tuple[MiniMaxH3PackedLayout, ...], )
)
def resolve_canvas_size(aspect_width: float, aspect_height: float) -> tuple[int, int]:
def resolve_canvas_size(
aspect_width: float,
aspect_height: float,
*,
short_edge: int = MINIMAX_H3_SHORT_EDGE,
max_pixels: int = MINIMAX_H3_MAX_PIXELS,
) -> tuple[int, int]:
"""Resolve a canvas from an aspect ratio and a tier's short-edge/area rules.
The defaults are the released MiniMax-H3 768p rule. Pika H3 Flex passes its
own 480p/768p tier values through the keyword arguments.
"""
if aspect_width <= 0 or aspect_height <= 0:
raise ValueError(f"The aspect ratio must be positive, got {aspect_width}:{aspect_height}.")
if short_edge <= 0 or max_pixels <= 0:
raise ValueError(f"Canvas short_edge and max_pixels must be positive, got {short_edge}, {max_pixels}.")
ratio = aspect_width / aspect_height
if not MINIMAX_H3_MIN_ASPECT_RATIO <= ratio <= MINIMAX_H3_MAX_ASPECT_RATIO:
raise ValueError(f"MiniMax-H3 supports aspect ratios from 1:4 to 4:1, got {aspect_width}:{aspect_height}.")
if ratio >= 1:
width, height = MINIMAX_H3_SHORT_EDGE * ratio, float(MINIMAX_H3_SHORT_EDGE)
width, height = short_edge * ratio, float(short_edge)
else:
width, height = float(MINIMAX_H3_SHORT_EDGE), MINIMAX_H3_SHORT_EDGE / ratio
width, height = float(short_edge), short_edge / ratio
area = width * height
if area > MINIMAX_H3_MAX_PIXELS:
scale = (MINIMAX_H3_MAX_PIXELS / area)**0.5
if area > max_pixels:
scale = (max_pixels / area)**0.5
width, height = width * scale, height * scale
multiple = MINIMAX_H3_CANVAS_MULTIPLE
return max(multiple, round(height / multiple) * multiple), max(multiple, round(width / multiple) * multiple)
@@ -22,6 +22,8 @@ from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_FRAMES_PER_CHUNK,
MINIMAX_H3_LATENTS_PER_CHUNK,
MINIMAX_H3_MAX_DURATION,
MINIMAX_H3_MAX_PIXELS,
MINIMAX_H3_SHORT_EDGE,
resolve_canvas_size,
)
@@ -180,13 +182,20 @@ def decode_reference_video(source: str | os.PathLike[str]) -> tuple[np.ndarray,
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."""
def resolve_reference_image_size(
width: int,
height: int,
*,
short_edge: int = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE,
) -> tuple[int, int]:
"""Resolve the released reference-image canvas at ``short_edge``."""
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)
if short_edge <= 0:
raise ValueError(f"A reference-image short edge must be positive, got {short_edge}.")
scale = short_edge / min(width, height)
multiple = MINIMAX_H3_CANVAS_MULTIPLE
return (
max(multiple,
@@ -231,12 +240,23 @@ def resample_reference_frames(frames: np.ndarray, fps: float) -> np.ndarray:
return np.repeat(frames, repeats, axis=0)
def prepare_reference_frames(frames: np.ndarray, num_frames: int) -> np.ndarray:
def prepare_reference_frames(
frames: np.ndarray,
num_frames: int,
*,
short_edge: int = MINIMAX_H3_SHORT_EDGE,
max_pixels: int = MINIMAX_H3_MAX_PIXELS,
) -> 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])
height, width = resolve_canvas_size(
frames.shape[2],
frames.shape[1],
short_edge=short_edge,
max_pixels=max_pixels,
)
if frames.shape[1:3] == (height, width):
return frames
return np.stack(
@@ -319,8 +339,17 @@ def prepare_reference(
reference: MiniMaxH3Reference,
num_frames: int,
target_sample_rate: int,
*,
reference_short_edge: int = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE,
reference_canvas_short_edge: int = MINIMAX_H3_SHORT_EDGE,
reference_canvas_max_pixels: int = MINIMAX_H3_MAX_PIXELS,
) -> MiniMaxH3PreparedReference:
"""Decode and normalize one deferred Ref2VA medium."""
"""Decode and normalize one deferred Ref2VA medium.
The keyword arguments let a family use its own tier's reference-image short
edge and reference-video canvas rule. MiniMax-H3's released values are the
defaults; Pika H3 Flex passes its 480p/768p tier values.
"""
prepared = MiniMaxH3PreparedReference(media_type=reference.media_type)
if reference.media_type == "image":
source = reference.source
@@ -331,7 +360,7 @@ def prepare_reference(
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)
height, width = resolve_reference_image_size(*image.size, short_edge=reference_short_edge)
prepared.image = prepare_reference_image(image, height, width)
return prepared
@@ -346,7 +375,12 @@ def prepare_reference(
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)
prepared.frames = prepare_reference_frames(
resample_reference_frames(frames, fps),
num_frames,
short_edge=reference_canvas_short_edge,
max_pixels=reference_canvas_max_pixels,
)
if reference.soundtrack is not None:
waveform, source_rate = _resolve_audio_source(reference.soundtrack)
@@ -133,14 +133,21 @@ class MiniMaxH3DenoisingStage(PipelineStage):
self.scheduler = scheduler
self.audio_scheduler = audio_scheduler
def _set_dmd_schedule(self, steps: list[int], grid_points: int, device: torch.device) -> None:
"""Run the trained rungs, shifting the shared noise clock once per modality."""
def _set_shifted_schedule(
self,
steps: list[int],
grid_points: int,
device: torch.device,
*,
label: str = "MiniMax-H3 DMD",
) -> None:
"""Build one modality-shifted sigma grid from explicit clean-time rungs."""
if (not steps or any(type(step) is not int or not 0 < step <= 1000 for step in steps)
or any(left <= right for left, right in zip(steps, steps[1:], strict=False))):
raise ValueError("MiniMax-H3 DMD rungs must be strictly decreasing integers in (0, 1000].")
raise ValueError(f"{label} rungs must be strictly decreasing integers in (0, 1000].")
if grid_points != len(steps) + 1:
raise ValueError("MiniMax-H3 num_inference_steps counts sigma-grid points: "
f"{len(steps)} DMD forwards require {len(steps) + 1} grid points, got {grid_points}.")
raise ValueError(f"{label} num_inference_steps counts sigma-grid points: "
f"{len(steps)} forwards require {len(steps) + 1} grid points, got {grid_points}.")
base = torch.tensor([step / 1000.0 for step in steps] + [0.0], dtype=torch.float32)
for scheduler in (self.scheduler, self.audio_scheduler):
shift = float(scheduler.shift)
@@ -149,6 +156,38 @@ class MiniMaxH3DenoisingStage(PipelineStage):
# clean time (1 - sigma); passing integer rungs to step() is wrong.
scheduler.set_timesteps(sigmas=sigmas, device=device)
def _set_dmd_schedule(self, steps: list[int], grid_points: int, device: torch.device) -> None:
"""Run the trained rungs, shifting the shared noise clock once per modality."""
self._set_shifted_schedule(steps, grid_points, device, label="MiniMax-H3 DMD")
def _configure_schedules(
self,
batch: ForwardBatch,
fastvideo_args: FastVideoArgs,
device: torch.device,
) -> PDDSamplingPlan | None:
"""Install this checkpoint's sigma schedules and return a PDD plan, if any.
Families with a fixed sampler (Pika H3 Flex) override this hook; the
default keeps MiniMax-H3's PDD and DMD checkpoint contracts.
"""
dmd_steps = fastvideo_args.pipeline_config.dmd_denoising_steps
pdd_plan = self._pdd_sampling_plan(fastvideo_args, device)
if pdd_plan is not None:
# One runtime step fuses one block of heads into their weighted
# mean velocity; the ordinary Euler step over the block's two node
# sigmas then applies the block's total increment per modality.
self.scheduler.set_timesteps(sigmas=pdd_plan.node_sigmas["video"].to(torch.float32), device=device)
self.audio_scheduler.set_timesteps(sigmas=pdd_plan.node_sigmas["audio"].to(torch.float32), device=device)
logger.info("MiniMax-H3 PDD denoising: %d fused block(s) %s over a %d-interval fine grid.",
pdd_plan.num_steps, pdd_plan.block_sizes, pdd_plan.pdd_steps)
elif dmd_steps is None:
self.scheduler.set_timesteps(batch.num_inference_steps, device=device)
self.audio_scheduler.set_timesteps(batch.num_inference_steps, device=device)
else:
self._set_dmd_schedule(dmd_steps, batch.num_inference_steps, device)
return pdd_plan
def _pdd_sampling_plan(self, fastvideo_args: FastVideoArgs, device: torch.device) -> PDDSamplingPlan | None:
"""The fused-block plan of a PDD checkpoint, or None for any other checkpoint.
@@ -207,21 +246,7 @@ class MiniMaxH3DenoisingStage(PipelineStage):
and not fastvideo_args.use_fsdp_inference)
device = get_local_torch_device()
dmd_steps = fastvideo_args.pipeline_config.dmd_denoising_steps
pdd_plan = self._pdd_sampling_plan(fastvideo_args, device)
if pdd_plan is not None:
# One runtime step fuses one block of heads into their weighted
# mean velocity; the ordinary Euler step over the block's two node
# sigmas then applies the block's total increment per modality.
self.scheduler.set_timesteps(sigmas=pdd_plan.node_sigmas["video"].to(torch.float32), device=device)
self.audio_scheduler.set_timesteps(sigmas=pdd_plan.node_sigmas["audio"].to(torch.float32), device=device)
logger.info("MiniMax-H3 PDD denoising: %d fused block(s) %s over a %d-interval fine grid.",
pdd_plan.num_steps, pdd_plan.block_sizes, pdd_plan.pdd_steps)
elif dmd_steps is None:
self.scheduler.set_timesteps(batch.num_inference_steps, device=device)
self.audio_scheduler.set_timesteps(batch.num_inference_steps, device=device)
else:
self._set_dmd_schedule(dmd_steps, batch.num_inference_steps, device)
pdd_plan = self._configure_schedules(batch, fastvideo_args, device)
video_timesteps = self.scheduler.timesteps
audio_timesteps = self.audio_scheduler.timesteps
if video_timesteps is None or audio_timesteps is None:
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.pipelines.basic.pika_h3_flex.pipeline import (
EntryClass,
PikaH3FlexModularPipeline,
PikaH3FlexRef2VAModularPipeline,
)
from fastvideo.pipelines.basic.pika_h3_flex.adapter import PikaAdapter
__all__ = [
"EntryClass",
"PikaAdapter",
"PikaH3FlexModularPipeline",
"PikaH3FlexRef2VAModularPipeline",
]
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
"""Locate and validate Pika H3 Flex's released LoRA adapters.
Pika H3 Flex is MiniMax-H3 plus one of two rank-128 PEFT adapters: ``fl2va``
for text/first-frame/last-frame generation and ``ref2va`` for ordered
image/video/audio references. The adapter is part of the model contract, not a
user-tunable accelerator, so the pipeline loads it automatically and refuses a
manifest whose rank, trunk, or sampler does not match the release.
This module stays torch-free; the manifest is a plain JSON document and the
only optional dependency is ``huggingface_hub`` for a repo-id source.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from collections.abc import Mapping
from fastvideo.configs.pipelines.pika_h3_flex import (
PIKA_AUDIO_SCHEDULER_SHIFT,
PIKA_DENOISING_STEPS,
PIKA_VIDEO_SCHEDULER_SHIFT,
)
PIKA_ADAPTER_FORMAT_VERSION = 1
PIKA_ADAPTER_RANK = 128
PIKA_ADAPTER_ALPHA = 128
PIKA_BASE_REPO = "MiniMaxAI/MiniMax-H3"
PIKA_ADAPTER_FILENAME = "adapter.safetensors"
PIKA_MANIFEST_FILENAME = "manifest.json"
PIKA_SAMPLING_CONTRACT = {
"knots": (*PIKA_DENOISING_STEPS, 0),
"video_shift": PIKA_VIDEO_SCHEDULER_SHIFT,
"audio_shift": PIKA_AUDIO_SCHEDULER_SHIFT,
"method": "renoise",
}
PIKA_TARGET_MODULES = (
"to_q",
"to_k",
"to_v",
"to_out.0",
"ff.net.0.proj",
"ff.net.2",
)
TASK_TO_TRUNK = {"fl2va": "transformer", "ref2va": "transformer_ref"}
class PikaAdapterError(ValueError):
"""The Pika adapter is missing or does not describe the released model."""
@dataclass(frozen=True)
class PikaAdapter:
"""One validated released adapter."""
task: str
trunk: str
path: str
manifest_path: str
manifest: dict[str, Any]
@property
def directory(self) -> str:
return str(Path(self.path).parent)
def task_for_pipeline(ref2va: bool) -> str:
return "ref2va" if ref2va else "fl2va"
def expected_trunk(task: str) -> str:
try:
return TASK_TO_TRUNK[task]
except KeyError as error:
raise PikaAdapterError(
f"Unknown Pika H3 Flex task {task!r}; expected one of {sorted(TASK_TO_TRUNK)}.") from error
def _as_int(value: Any, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise PikaAdapterError(f"Pika adapter manifest field {field!r} must be an integer, got {value!r}.")
return value
def _as_float(value: Any, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, int | float):
raise PikaAdapterError(f"Pika adapter manifest field {field!r} must be a number, got {value!r}.")
return float(value)
def validate_adapter_manifest(
manifest: Mapping[str, Any],
*,
task: str,
pipeline_trunk: str | None = None,
) -> None:
"""Reject a manifest that is not the released Pika adapter for ``task``."""
trunk = expected_trunk(task)
if pipeline_trunk is not None and pipeline_trunk != trunk:
raise PikaAdapterError(f"Pika H3 Flex task {task!r} loads trunk {trunk!r}, but the pipeline requested "
f"{pipeline_trunk!r}.")
format_version = _as_int(manifest.get("format_version"), "format_version")
if format_version != PIKA_ADAPTER_FORMAT_VERSION:
raise PikaAdapterError(f"Unsupported Pika adapter manifest format {format_version}; expected "
f"{PIKA_ADAPTER_FORMAT_VERSION}.")
manifest_task = manifest.get("task")
if manifest_task is not None and manifest_task != task:
raise PikaAdapterError(f"The adapter at this path is for task {manifest_task!r}, not {task!r}.")
rank = _as_int(manifest.get("rank"), "rank")
alpha = _as_int(manifest.get("alpha"), "alpha")
if rank != PIKA_ADAPTER_RANK or alpha != PIKA_ADAPTER_ALPHA:
raise PikaAdapterError(f"Pika H3 Flex adapters are rank {PIKA_ADAPTER_RANK} with alpha "
f"{PIKA_ADAPTER_ALPHA}; this manifest says rank {rank}, alpha {alpha}.")
manifest_trunk = manifest.get("trunk")
if manifest_trunk != trunk:
raise PikaAdapterError(f"Pika H3 Flex task {task!r} expects adapter trunk {trunk!r}, got "
f"{manifest_trunk!r}.")
sampling = manifest.get("sampling")
if not isinstance(sampling, Mapping):
raise PikaAdapterError("Pika adapter manifest is missing its `sampling` contract.")
knots = sampling.get("knots")
if not isinstance(knots, list | tuple) or tuple(knots) != PIKA_SAMPLING_CONTRACT["knots"]:
raise PikaAdapterError(f"Pika adapter sampling knots must be "
f"{list(PIKA_SAMPLING_CONTRACT['knots'])}, got {knots!r}.")
for field in ("video_shift", "audio_shift"):
value = _as_float(sampling.get(field), f"sampling.{field}")
expected = float(PIKA_SAMPLING_CONTRACT[field])
if abs(value - expected) > 1e-9:
raise PikaAdapterError(f"Pika adapter sampling.{field} must be {expected:g}, got {value:g}.")
if sampling.get("method") != PIKA_SAMPLING_CONTRACT["method"]:
raise PikaAdapterError(f"Pika adapter sampling.method must be "
f"{PIKA_SAMPLING_CONTRACT['method']!r}, got {sampling.get('method')!r}.")
target_modules = manifest.get("target_modules")
if not isinstance(target_modules, list | tuple) or tuple(target_modules) != PIKA_TARGET_MODULES:
raise PikaAdapterError(f"Pika adapter target_modules must be {list(PIKA_TARGET_MODULES)}, got "
f"{target_modules!r}.")
base = manifest.get("base")
if not isinstance(base, Mapping) or base.get("repo_id") != PIKA_BASE_REPO:
raise PikaAdapterError(f"Pika adapter base must name {PIKA_BASE_REPO!r}, got {base!r}.")
def _load_manifest(path: Path) -> dict[str, Any]:
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except OSError as error:
raise PikaAdapterError(f"Cannot read Pika adapter manifest {str(path)!r}: {error}") from error
except json.JSONDecodeError as error:
raise PikaAdapterError(f"Pika adapter manifest {str(path)!r} is not valid JSON: {error}") from error
if not isinstance(manifest, dict):
raise PikaAdapterError(f"Pika adapter manifest {str(path)!r} must be a JSON object.")
return manifest
def _adapter_in_directory(directory: Path, task: str) -> tuple[Path, Path] | None:
nested = directory / task / PIKA_ADAPTER_FILENAME
if nested.is_file():
manifest = directory / task / PIKA_MANIFEST_FILENAME
return nested, manifest
flat = directory / PIKA_ADAPTER_FILENAME
if flat.is_file():
manifest = directory / PIKA_MANIFEST_FILENAME
return flat, manifest
return None
def _looks_like_repo_id(source: str) -> bool:
parts = source.split("/")
return len(parts) == 2 and all(parts) and "\\" not in source
def _download_adapter(source: str, task: str, revision: str | None) -> tuple[Path, Path]:
try:
from huggingface_hub import hf_hub_download
except ImportError as error: # pragma: no cover - huggingface_hub is a runtime dependency
raise PikaAdapterError("Downloading a Pika adapter by repo id requires huggingface_hub.") from error
try:
adapter = Path(hf_hub_download(
repo_id=source,
filename=f"{task}/{PIKA_ADAPTER_FILENAME}",
revision=revision,
))
manifest = Path(
hf_hub_download(
repo_id=source,
filename=f"{task}/{PIKA_MANIFEST_FILENAME}",
revision=revision,
))
except Exception as error:
raise PikaAdapterError(f"Could not download the Pika H3 Flex {task} adapter from {source!r}"
f"{f' at revision {revision!r}' if revision else ''}: {error}") from error
return adapter, manifest
def load_adapter(source: str | os.PathLike[str], *, task: str, revision: str | None = None) -> PikaAdapter:
"""Resolve ``source`` (adapter file, release directory, task directory, or repo id)."""
trunk = expected_trunk(task)
raw = os.fspath(source)
path = Path(raw).expanduser()
if path.is_file():
adapter_path, manifest_path = path, path.with_name(PIKA_MANIFEST_FILENAME)
elif path.is_dir():
found = _adapter_in_directory(path, task)
if found is None:
raise PikaAdapterError(f"{raw!r} has no {task}/{PIKA_ADAPTER_FILENAME} or {PIKA_ADAPTER_FILENAME}.")
adapter_path, manifest_path = found
elif _looks_like_repo_id(raw):
adapter_path, manifest_path = _download_adapter(raw, task, revision)
else:
raise PikaAdapterError(f"Pika adapter path {raw!r} does not exist and is not a Hub repo id.")
if not adapter_path.is_file():
raise PikaAdapterError(f"Pika adapter weights are missing at {str(adapter_path)!r}.")
if not manifest_path.is_file():
raise PikaAdapterError(f"Pika adapter manifest is missing at {str(manifest_path)!r}; a bare "
f"{PIKA_ADAPTER_FILENAME} cannot be checked against the release contract.")
manifest = _load_manifest(manifest_path)
validate_adapter_manifest(manifest, task=task, pipeline_trunk=trunk)
return PikaAdapter(
task=task,
trunk=trunk,
path=str(adapter_path),
manifest_path=str(manifest_path),
manifest=manifest,
)
def adapter_hf_patterns(task: str | None = None) -> list[str]:
"""The adapter files the pipeline must include in a partial Hub snapshot."""
tasks = tuple(TASK_TO_TRUNK) if task is None else (task, )
patterns: list[str] = []
for item in tasks:
expected_trunk(item)
patterns.extend((f"{item}/{PIKA_ADAPTER_FILENAME}", f"{item}/{PIKA_MANIFEST_FILENAME}"))
return patterns
__all__ = [
"PIKA_ADAPTER_ALPHA",
"PIKA_ADAPTER_FILENAME",
"PIKA_ADAPTER_FORMAT_VERSION",
"PIKA_ADAPTER_RANK",
"PIKA_BASE_REPO",
"PIKA_MANIFEST_FILENAME",
"PIKA_SAMPLING_CONTRACT",
"PIKA_TARGET_MODULES",
"PikaAdapter",
"PikaAdapterError",
"TASK_TO_TRUNK",
"adapter_hf_patterns",
"expected_trunk",
"load_adapter",
"task_for_pipeline",
"validate_adapter_manifest",
]
@@ -0,0 +1,294 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex resolution buckets and frame grid.
Pika H3 Flex keeps MiniMax-H3's canvas rule: sides sit on a 32-pixel lattice,
a fixed short edge bounds the bucket, and an area cap keeps the token count
finite. The open-weight release samples at two of the released tiers, 480p and
768p; the hosted API's larger tiers are intentionally not exposed here because
the local checkpoints were not trained for them.
A request selects a bucket, not a free-form canvas. ``height`` and ``width``
are therefore aspect hints: an exact bucket is honoured, anything else is
snapped to the nearest aspect family of the tier implied by the requested
short edge. Keeping this arithmetic in a torch-free module lets the geometry
contract be tested on a CPU-only host.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from collections.abc import Iterable
PIKA_FPS = 24
PIKA_CANVAS_MULTIPLE = 32
PIKA_MIN_ASPECT_RATIO = 0.25
PIKA_MAX_ASPECT_RATIO = 4.0
# Landscape families first, then square, then the mirrored portraits.
PIKA_ASPECTS: tuple[str, ...] = (
"21:9",
"16:9",
"3:2",
"4:3",
"5:4",
"1:1",
"4:5",
"3:4",
"2:3",
"9:16",
"9:21",
)
# 17n + 5 from 2.3 s to 15.1 s at 24 fps. Open-weight requests are limited to
# the documented 5-15 s range; the two smaller grid points remain part of the
# released grid but are rejected by the request validator.
PIKA_FRAME_GRID: tuple[int, ...] = tuple(17 * n + 5 for n in range(3, 22))
PIKA_MIN_DURATION = 5.0
PIKA_MAX_DURATION = 15.0
PIKA_MIN_ALIGNED_FRAMES = 120
PIKA_MAX_ALIGNED_FRAMES = 362
@dataclass(frozen=True)
class PikaTier:
"""One released resolution tier."""
name: str
short_edge: int
max_pixels: int
reference_short_edge: int
PIKA_TIERS: dict[str, PikaTier] = {
"p480": PikaTier("p480", short_edge=480, max_pixels=832 * 480, reference_short_edge=1024),
"p768": PikaTier("p768", short_edge=768, max_pixels=1344 * 768, reference_short_edge=2048),
}
DEFAULT_TIER = "p768"
DEFAULT_ASPECT: tuple[int, int] = (16, 9)
_TIER_ALIASES = {
"480p": "p480",
"p480": "p480",
"480": "p480",
"768p": "p768",
"p768": "p768",
"768": "p768",
}
def resolve_tier(value: str | None) -> str:
"""Normalize ``480p`` / ``p480`` / ``768p`` / ``p768`` to a released tier name."""
if value is None:
return DEFAULT_TIER
key = str(value).strip().lower()
tier = _TIER_ALIASES.get(key)
if tier is None:
raise ValueError(f"Pika H3 Flex only samples the released 480p and 768p tiers; got {value!r}.")
return tier
def tier_rule(tier: str | None) -> PikaTier:
name = resolve_tier(tier)
return PIKA_TIERS[name]
def _parse_aspect(aspect: str | tuple[int, int] | tuple[float, float]) -> tuple[float, float]:
if isinstance(aspect, str):
parts = aspect.split(":")
if len(parts) != 2:
raise ValueError(f"An aspect ratio must be W:H, got {aspect!r}.")
width, height = float(parts[0]), float(parts[1])
elif isinstance(aspect, tuple) and len(aspect) == 2:
width, height = float(aspect[0]), float(aspect[1])
else:
raise TypeError(f"An aspect ratio must be a 'W:H' string or a (width, height) pair, got {aspect!r}.")
if not (math.isfinite(width) and math.isfinite(height)) or width <= 0 or height <= 0:
raise ValueError(f"An aspect ratio must be finite and positive, got {aspect!r}.")
return width, height
def aspect_ratio(width: float, height: float) -> float:
width, height = _parse_aspect((width, height))
return width / height
def canvas_for_aspect(aspect: str | tuple[float, float], tier: str | None = None) -> tuple[int, int]:
"""``(height, width)`` of one tier's bucket for an aspect family."""
rule = tier_rule(tier)
aspect_width, aspect_height = _parse_aspect(aspect)
ratio = aspect_width / aspect_height
if not PIKA_MIN_ASPECT_RATIO <= ratio <= PIKA_MAX_ASPECT_RATIO:
raise ValueError(f"Pika H3 Flex supports aspect ratios from 1:4 to 4:1, got {aspect!r}.")
if ratio >= 1:
width, height = rule.short_edge * ratio, float(rule.short_edge)
else:
width, height = float(rule.short_edge), rule.short_edge / ratio
area = width * height
if area > rule.max_pixels:
scale = (rule.max_pixels / area)**0.5
width, height = width * scale, height * scale
multiple = PIKA_CANVAS_MULTIPLE
return (
max(multiple,
round(height / multiple) * multiple),
max(multiple,
round(width / multiple) * multiple),
)
def aspect_families(tier: str | None = None) -> tuple[str, ...]:
del tier
return PIKA_ASPECTS
def bucket_table(tier: str | None = None) -> tuple[tuple[int, int], ...]:
"""All ``(width, height)`` buckets of one tier, in :data:`PIKA_ASPECTS` order."""
return tuple((canvas_for_aspect(aspect, tier)[1], canvas_for_aspect(aspect, tier)[0]) for aspect in PIKA_ASPECTS)
def iter_buckets() -> Iterable[tuple[str, str, int, int]]:
"""Yield ``(tier, aspect, width, height)`` for every released bucket."""
for tier in PIKA_TIERS:
for aspect in PIKA_ASPECTS:
height, width = canvas_for_aspect(aspect, tier)
yield tier, aspect, width, height
def exact_bucket(height: int, width: int) -> tuple[str, str] | None:
"""The ``(tier, aspect)`` whose bucket is exactly ``height x width``, if any."""
for tier, aspect, bucket_width, bucket_height in iter_buckets():
if (bucket_height, bucket_width) == (int(height), int(width)):
return tier, aspect
return None
def tier_for_canvas(height: int, width: int) -> str:
"""The smallest released tier whose short edge covers the canvas."""
if height <= 0 or width <= 0:
raise ValueError(f"A canvas must be positive, got {height}x{width}.")
short = min(int(height), int(width))
for tier in sorted(PIKA_TIERS.values(), key=lambda rule: rule.short_edge):
if short <= tier.short_edge:
return tier.name
return max(PIKA_TIERS.values(), key=lambda rule: rule.short_edge).name
def reference_short_edge_for_canvas(height: int, width: int) -> int:
"""Reference-image short edge the released tier rule uses for an output canvas."""
return PIKA_TIERS[tier_for_canvas(height, width)].reference_short_edge
def _nearest_aspect(ratio: float, tier: str) -> tuple[int, int, str]:
best: tuple[float, str, tuple[int, int]] | None = None
for aspect in PIKA_ASPECTS:
family_ratio = aspect_ratio(*_parse_aspect(aspect))
distance = abs(math.log(ratio / family_ratio))
if best is None or distance < best[0]:
best = (distance, aspect, canvas_for_aspect(aspect, tier))
# PIKA_ASPECTS is ordered by family; equal distances keep the earlier one.
assert best is not None
height, width = best[2]
return height, width, best[1]
def nearest_bucket(height: int, width: int, tier: str) -> tuple[int, int, str]:
"""Snap ``height x width`` to the closest aspect family of ``tier``."""
if height <= 0 or width <= 0:
raise ValueError(f"A canvas must be positive, got {height}x{width}.")
ratio = width / height
if not PIKA_MIN_ASPECT_RATIO - 1e-9 <= ratio <= PIKA_MAX_ASPECT_RATIO + 1e-9:
raise ValueError(f"Pika H3 Flex supports aspect ratios from 1:4 to 4:1, got {width}x{height}.")
return _nearest_aspect(ratio, tier)
def resolve_canvas(
height: int | None,
width: int | None,
*,
default_aspect: str | tuple[float, float] = DEFAULT_ASPECT,
default_tier: str | None = DEFAULT_TIER,
) -> tuple[int, int, str, str]:
"""Resolve a request canvas to ``(height, width, tier, aspect)``.
With neither side set, the default tier and aspect are used. With both
sides set, an exact released bucket is honoured; otherwise the tier follows
the requested short edge (``<= 480`` is 480p, everything else 768p) and the
canvas snaps to the nearest aspect family. One-sided requests are rejected.
"""
if (height is None) != (width is None):
raise ValueError("Pika H3 Flex `height` and `width` must be passed together, or neither.")
if height is None:
tier = resolve_tier(default_tier)
aspect_width, aspect_height = _parse_aspect(default_aspect)
bucket_height, bucket_width = canvas_for_aspect((aspect_width, aspect_height), tier)
aspect = PIKA_ASPECTS[0]
for family in PIKA_ASPECTS:
if canvas_for_aspect(family, tier) == (bucket_height, bucket_width):
aspect = family
break
return bucket_height, bucket_width, tier, aspect
if not isinstance(height, int) or isinstance(height, bool) or not isinstance(width, int) or isinstance(width, bool):
raise TypeError("Pika H3 Flex `height` and `width` must be integers.")
if height <= 0 or width <= 0:
raise ValueError(f"Pika H3 Flex `height` and `width` must be positive, got {height}x{width}.")
exact = exact_bucket(height, width)
if exact is not None:
tier, aspect = exact
return height, width, tier, aspect
tier = "p480" if min(height, width) <= PIKA_TIERS["p480"].short_edge else "p768"
bucket_height, bucket_width, aspect = nearest_bucket(height, width, tier)
return bucket_height, bucket_width, tier, aspect
def resolve_frames(num_frames: int) -> int:
"""Round a request up to Pika's ``17n + 5`` grid inside the 5-15 s range."""
if not isinstance(num_frames, int) or isinstance(num_frames, bool):
raise TypeError(f"`num_frames` must be an integer, got {type(num_frames).__name__}.")
if num_frames < 1:
raise ValueError(f"`num_frames` must be positive, got {num_frames}.")
aligned = num_frames
while aligned % 17 != 5:
aligned += 1
if not PIKA_MIN_ALIGNED_FRAMES <= aligned <= PIKA_MAX_ALIGNED_FRAMES:
raise ValueError(f"Pika H3 Flex generates {PIKA_MIN_DURATION:g}-{PIKA_MAX_DURATION:g} seconds at "
f"{PIKA_FPS} fps; aligned num_frames={aligned} is outside the accepted "
f"{PIKA_MIN_ALIGNED_FRAMES}-{PIKA_MAX_ALIGNED_FRAMES} frame range.")
return aligned
def duration_seconds(num_frames: int) -> float:
return int(num_frames) / PIKA_FPS
__all__ = [
"DEFAULT_ASPECT",
"DEFAULT_TIER",
"PIKA_ASPECTS",
"PIKA_CANVAS_MULTIPLE",
"PIKA_FPS",
"PIKA_FRAME_GRID",
"PIKA_MAX_ALIGNED_FRAMES",
"PIKA_MAX_DURATION",
"PIKA_MIN_ALIGNED_FRAMES",
"PIKA_MIN_DURATION",
"PIKA_TIERS",
"PikaTier",
"aspect_ratio",
"bucket_table",
"canvas_for_aspect",
"duration_seconds",
"exact_bucket",
"iter_buckets",
"nearest_bucket",
"reference_short_edge_for_canvas",
"resolve_canvas",
"resolve_frames",
"resolve_tier",
"tier_for_canvas",
"tier_rule",
]
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex pipeline: MiniMax-H3 components, Pika adapter and sampler."""
from __future__ import annotations
import math
from typing import Any
from fastvideo.configs.pipelines.pika_h3_flex import (
PIKA_AUDIO_SCHEDULER_SHIFT,
PIKA_VIDEO_SCHEDULER_SHIFT,
PikaH3FlexPipelineConfig,
)
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import MiniMaxH3BasePipeline
from fastvideo.pipelines.basic.pika_h3_flex.adapter import adapter_hf_patterns, load_adapter, task_for_pipeline
from fastvideo.pipelines.basic.pika_h3_flex.stages.denoising import PikaH3FlexDenoisingStage
from fastvideo.pipelines.basic.pika_h3_flex.stages.input_preparation import PikaH3FlexInputPreparationStage
logger = init_logger(__name__)
class PikaH3FlexBasePipeline(MiniMaxH3BasePipeline):
"""Pika H3 Flex's adapter, bucket geometry, and six-forward re-noise sampler."""
pipeline_config_cls = PikaH3FlexPipelineConfig
input_preparation_stage_cls = PikaH3FlexInputPreparationStage
denoising_stage_cls = PikaH3FlexDenoisingStage
_ref2va_default = False
_pika_adapter: Any = None
@classmethod
def get_hf_download_allow_patterns(cls) -> list[str]:
patterns = super().get_hf_download_allow_patterns()
# The concrete class picks the adapter, so a T2VA download does not
# fetch the 2.7 GB Ref2VA adapter (and vice versa).
task = "ref2va" if issubclass(cls, PikaH3FlexRefPipeline) else "fl2va"
return [*patterns, *adapter_hf_patterns(task)]
def _resolve_pika_adapter(self) -> None:
"""Point ``lora_path`` at the released adapter before LoRA layers attach.
``_load_config`` runs before ``LoRAPipeline`` reads ``lora_path``, so the
automatic adapter behaves exactly like an explicitly passed one,
including the lazy/sequential transformer paths.
"""
task = task_for_pipeline(self._ref2va)
explicit = self.fastvideo_args.lora_path
source = explicit if explicit is not None else self.model_path
revision = None if explicit is not None else self.fastvideo_args.revision
adapter = load_adapter(source, task=task, revision=revision)
source_kind = "explicit" if explicit is not None else "released"
logger.info("Pika H3 Flex: loaded the %s %s adapter from %s", source_kind, task, adapter.path)
self._pika_adapter = adapter
self.fastvideo_args.lora_path = adapter.path
def _load_config(self, model_path: str) -> dict[str, Any]:
config = super()._load_config(model_path)
self._resolve_pika_adapter()
return config
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
super().initialize_pipeline(fastvideo_args)
expected = {"video": PIKA_VIDEO_SCHEDULER_SHIFT, "audio": PIKA_AUDIO_SCHEDULER_SHIFT}
for module_name, modality in (("scheduler", "video"), ("audio_scheduler", "audio")):
actual = float(self.get_module(module_name).shift)
target = expected[modality]
if not math.isfinite(actual) or abs(actual - target) > 1e-9:
raise ValueError(f"Pika H3 Flex samples with {modality} shift {target:g}, but "
f"{module_name}/scheduler_config.json declares {actual:g}.")
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
self._add_stages(fastvideo_args, ref2va=self._ref2va_default)
class PikaH3FlexPipeline(PikaH3FlexBasePipeline):
"""T2VA/I2VA/L2VA/FL2VA pipeline through the ``fl2va`` adapter."""
_ref2va_default = False
class PikaH3FlexRefPipeline(PikaH3FlexBasePipeline):
"""Ordered-reference pipeline through the ``ref2va`` adapter and ``transformer_ref``."""
_extra_config_module_map = {"transformer": "transformer_ref"}
_ref2va_default = True
class PikaH3FlexModularPipeline(PikaH3FlexPipeline):
"""Public entry matching the released ``modular_model_index.json`` class name."""
class PikaH3FlexRef2VAModularPipeline(PikaH3FlexRefPipeline):
"""Public Ref2VA entry matching the released modular class name."""
EntryClass = [PikaH3FlexModularPipeline, PikaH3FlexRef2VAModularPipeline]
__all__ = [
"EntryClass",
"PikaH3FlexBasePipeline",
"PikaH3FlexModularPipeline",
"PikaH3FlexPipeline",
"PikaH3FlexRef2VAModularPipeline",
"PikaH3FlexRefPipeline",
]
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
"""Inference presets for Pika H3 Flex joint video/audio generation."""
from fastvideo.api.presets import InferencePreset, PresetStageSpec
# The six-forward ladder is the adapter's training contract. Exposing a stage
# override would let a request silently sample the adapter outside its
# distribution, so the denoise stage accepts no overrides.
_DENOISE_STAGE = PresetStageSpec(
name="denoise",
kind="denoising",
description="Pika's fixed six-forward re-noise ladder",
allowed_overrides=frozenset(),
)
_SHARED_DEFAULTS = {
"fps": 24,
"guidance_scale": 1.0,
"batch_cfg": False,
"negative_prompt": "",
"num_inference_steps": 6,
"seed": 0,
"height": 768,
"width": 1344,
"num_frames": 124,
}
PIKA_H3_FLEX_T2VA = InferencePreset(
name="pika_h3_flex_t2va",
version=1,
model_family="pika_h3_flex",
description="Pika H3 Flex text-to-video with synchronized stereo audio at 768p",
workload_type="t2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults=dict(_SHARED_DEFAULTS),
)
PIKA_H3_FLEX_FL2VA = InferencePreset(
name="pika_h3_flex_fl2va",
version=1,
model_family="pika_h3_flex",
description="Pika H3 Flex first/last-frame-to-video with synchronized stereo audio",
workload_type="i2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults=dict(_SHARED_DEFAULTS),
)
PIKA_H3_FLEX_REF2VA = InferencePreset(
name="pika_h3_flex_ref2va",
version=1,
model_family="pika_h3_flex",
description="Pika H3 Flex ordered image/video/audio references to joint video and stereo audio",
workload_type="i2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults=dict(_SHARED_DEFAULTS),
)
ALL_PRESETS = (PIKA_H3_FLEX_T2VA, PIKA_H3_FLEX_FL2VA, PIKA_H3_FLEX_REF2VA)
__all__ = [
"ALL_PRESETS",
"PIKA_H3_FLEX_FL2VA",
"PIKA_H3_FLEX_REF2VA",
"PIKA_H3_FLEX_T2VA",
]
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.pipelines.basic.pika_h3_flex.stages.denoising import PikaH3FlexDenoisingStage
from fastvideo.pipelines.basic.pika_h3_flex.stages.input_preparation import PikaH3FlexInputPreparationStage
__all__ = ["PikaH3FlexDenoisingStage", "PikaH3FlexInputPreparationStage"]
@@ -0,0 +1,59 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex's fixed six-forward re-noise denoiser."""
from __future__ import annotations
import torch
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_denoising import MiniMaxH3DenoisingStage
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
def request_seed(batch: ForwardBatch) -> int:
"""The request seed, falling back to the generator's initial seed."""
if batch.seed is not None:
return int(batch.seed)
generator = batch.generator
if isinstance(generator, list):
generator = generator[0] if generator else None
if generator is None:
return 0
return int(generator.initial_seed())
class PikaH3FlexDenoisingStage(MiniMaxH3DenoisingStage):
"""Six full-model forwards over Pika's clean-time ladder.
Pika's adapter is not a PDD student: every rung is an ordinary transformer
forward. What differs from MiniMax-H3 is the ladder and the re-noise step,
which draws fresh Gaussian noise from a generator seeded ``seed + 1``.
"""
def _configure_schedules(
self,
batch: ForwardBatch,
fastvideo_args: FastVideoArgs,
device: torch.device,
):
steps = list(fastvideo_args.pipeline_config.pika_denoising_steps)
if batch.num_inference_steps != len(steps):
raise ValueError(f"Pika H3 Flex needs {len(steps)} transformer forwards plus the terminal zero, got "
f"num_inference_steps={batch.num_inference_steps}.")
for scheduler in (self.scheduler, self.audio_scheduler):
if not getattr(scheduler, "stochastic_sampling", False):
raise ValueError("Pika H3 Flex needs the released stochastic (re-noise) scheduler; "
f"{type(scheduler).__name__} did not load stochastic_sampling=true.")
self._set_shifted_schedule(steps, len(steps) + 1, device, label="Pika H3 Flex")
return None
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
generator = torch.Generator(device=get_local_torch_device()).manual_seed(request_seed(batch) + 1)
self.scheduler.set_noise_generator(generator)
self.audio_scheduler.set_noise_generator(generator)
return super().forward(batch, fastvideo_args)
__all__ = ["PikaH3FlexDenoisingStage", "request_seed"]
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex input preparation: bucket canvas, cropped keyframes, tiered references."""
from __future__ import annotations
from typing import Any
import torch
from PIL import Image, ImageOps
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models.vision_utils import load_image
from fastvideo.pipelines.basic.minimax_h3.packing import prepare_keyframe_image
from fastvideo.pipelines.basic.minimax_h3.reference import (
MiniMaxH3PreparedReference,
prepare_reference,
validate_references,
)
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import (
MINIMAX_H3_KEYFRAME_ANCHORS_KEY,
MINIMAX_H3_KEYFRAMES_KEY,
MiniMaxH3InputPreparationStage,
prepare_common_request,
)
from fastvideo.pipelines.basic.pika_h3_flex.buckets import (
PIKA_TIERS,
resolve_canvas,
resolve_frames,
resolve_tier,
)
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
logger = init_logger(__name__)
# Pika's released reference-video floor: two seconds at 24 fps is 48 frames,
# but the adapter's own validator requires at least 22 decoded frames.
PIKA_MIN_REFERENCE_VIDEO_FRAMES = 22
class PikaH3FlexInputPreparationStage(MiniMaxH3InputPreparationStage):
"""Prepare T2VA/I2VA/L2VA/FL2VA or Ref2VA inputs on a Pika bucket."""
def __init__(self, vae: Any, audio_vae: Any | None = None, *, ref2va: bool = False) -> None:
super().__init__(vae, audio_vae, ref2va=ref2va)
self._tier = "p768"
def _request_tier(self, fastvideo_args: FastVideoArgs) -> str:
return resolve_tier(getattr(fastvideo_args.pipeline_config, "pika_resolution_tier", None))
def _resolve_canvas(
self,
batch: ForwardBatch,
default_aspect: tuple[int, int],
) -> tuple[int, int, str, str]:
height, width, tier, aspect = resolve_canvas(
batch.height,
batch.width,
default_aspect=default_aspect,
default_tier=self._tier,
)
if (batch.height, batch.width) != (height, width):
logger.info("Pika H3 Flex snapped canvas %sx%s to %sx%s (%s %s).", batch.height, batch.width, height, width,
tier, aspect)
ratio = int(self.vae.spatial_compression_ratio)
if height % ratio or width % ratio:
raise ValueError(f"Pika H3 Flex canvas {height}x{width} is not divisible by VAE ratio {ratio}.")
return height, width, tier, aspect
def _prepare_fl2va(self, batch: ForwardBatch) -> None:
if batch.references:
raise ValueError("Pika H3 Flex references belong to the Ref2VA pipeline.")
if batch.image_path is not None:
batch.pil_image = load_image(batch.image_path)
for name, image in (("pil_image", batch.pil_image), ("last_image", batch.last_image)):
if image is not None and not isinstance(image, Image.Image):
raise TypeError(f"Pika H3 Flex `{name}` must be a PIL image, got {type(image).__name__}.")
raw_keyframes = [(anchor, ImageOps.exif_transpose(image).convert("RGB"))
for anchor, image in (("first", batch.pil_image), ("last", batch.last_image))
if image is not None]
default_aspect = raw_keyframes[0][1].size if raw_keyframes else (16, 9)
height, width, _, _ = self._resolve_canvas(batch, default_aspect)
num_frames = resolve_frames(batch.num_frames)
ratio = int(self.vae.spatial_compression_ratio)
self._write_target_geometry(batch, height, width, ratio, num_frames)
batch.extra[MINIMAX_H3_KEYFRAME_ANCHORS_KEY] = tuple(anchor for anchor, _ in raw_keyframes)
# Pika crops both endpoints. MiniMax-H3 stretches the first frame; using
# that fit here would distort every Pika image on a bucketed canvas.
batch.extra[MINIMAX_H3_KEYFRAMES_KEY] = [
prepare_keyframe_image(image, height, width, stretch=False) for _, image in raw_keyframes
]
def _prepare_ref2va(self, batch: ForwardBatch) -> None:
if batch.image_path is not None or batch.pil_image is not None or batch.last_image is not None:
raise ValueError("Pika H3 Flex Ref2VA accepts media through `references`, not FL2VA keyframe fields.")
references = validate_references(list(batch.references or []))
if self.audio_vae is None:
raise RuntimeError("Pika H3 Flex Ref2VA input preparation has no audio VAE.")
height, width, tier, _ = self._resolve_canvas(batch, (16, 9))
num_frames = resolve_frames(batch.num_frames)
target_sample_rate = int(self.audio_vae.sampling_rate)
rule = PIKA_TIERS[tier]
prepared: list[MiniMaxH3PreparedReference] = []
for reference in references:
item = prepare_reference(
reference,
num_frames,
target_sample_rate,
reference_short_edge=rule.reference_short_edge,
reference_canvas_short_edge=rule.short_edge,
reference_canvas_max_pixels=rule.max_pixels,
)
if (item.media_type == "video" and item.frames is not None
and item.frames.shape[0] < PIKA_MIN_REFERENCE_VIDEO_FRAMES):
raise ValueError(f"A Pika H3 Flex reference video needs at least "
f"{PIKA_MIN_REFERENCE_VIDEO_FRAMES} decoded frames at 24 fps; got "
f"{item.frames.shape[0]}.")
prepared.append(item)
if not prepared:
raise ValueError("Pika H3 Flex Ref2VA requires at least one non-audio reference.")
batch.references = prepared
self._write_target_geometry(batch, height, width, int(self.vae.spatial_compression_ratio), num_frames)
batch.extra[MINIMAX_H3_KEYFRAMES_KEY] = []
batch.extra[MINIMAX_H3_KEYFRAME_ANCHORS_KEY] = ()
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
prepare_common_request(batch)
self._tier = self._request_tier(fastvideo_args)
if self.ref2va:
self._prepare_ref2va(batch)
else:
self._prepare_fl2va(batch)
return batch
__all__ = ["PIKA_MIN_REFERENCE_VIDEO_FRAMES", "PikaH3FlexInputPreparationStage"]
+25
View File
@@ -42,6 +42,7 @@ from fastvideo.configs.pipelines.flux_2 import (
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
from fastvideo.configs.pipelines.pika_h3_flex import PikaH3FlexPipelineConfig
from fastvideo.configs.pipelines.mmaudio import (
MMAudioLarge44kV2PipelineConfig,
MMAudioLarge44kV2AConfig,
@@ -60,6 +61,7 @@ from fastvideo.api.sampling_param import SamplingParam
from fastvideo.api.matrixgame2 import MatrixGame2SamplingParam
from fastvideo.api.matrixgame3 import MatrixGame3SamplingParam
from fastvideo.api.flux import FluxSamplingParam
from fastvideo.api.pika_h3_flex import PikaH3FlexSamplingParam
from fastvideo.fastvideo_args import WorkloadType
from fastvideo.logger import init_logger
@@ -1152,6 +1154,26 @@ def _register_configs() -> None:
_register_wan_configs(WAN_MODEL_DEFINITIONS)
# Pika H3 Flex. It reuses MiniMax-H3's components, but its six-forward
# re-noise sampler and bucket geometry are a separate family contract.
register_configs(
sampling_param_cls=PikaH3FlexSamplingParam,
pipeline_config_cls=PikaH3FlexPipelineConfig,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=["Pika-Labs/Pika-H3-Flex"],
model_detectors=[
lambda path: any(token in path.lower() for token in (
"pika-h3-flex",
"pika_h3_flex",
"pikah3flexmodularpipeline",
"pikah3flexref2vamodularpipeline",
)),
],
model_family="pika_h3_flex",
default_preset="pika_h3_flex_t2va",
pipeline_cls_name="PikaH3FlexModularPipeline",
)
# MiniMax H3
register_configs(
sampling_param_cls=None,
@@ -1350,6 +1372,8 @@ def _register_presets() -> None:
ALL_PRESETS as MATRIXGAME3_PRESETS, )
from fastvideo.pipelines.basic.minimax_h3.presets import (
ALL_PRESETS as MINIMAX_H3_PRESETS, )
from fastvideo.pipelines.basic.pika_h3_flex.presets import (
ALL_PRESETS as PIKA_H3_FLEX_PRESETS, )
from fastvideo.pipelines.basic.mmaudio.presets import (
ALL_PRESETS as MMAUDIO_PRESETS, )
from fastvideo.pipelines.basic.sd35.presets import (
@@ -1383,6 +1407,7 @@ def _register_presets() -> None:
MATRIXGAME2_PRESETS,
MATRIXGAME3_PRESETS,
MINIMAX_H3_PRESETS,
PIKA_H3_FLEX_PRESETS,
MMAUDIO_PRESETS,
SD35_PRESETS,
STABLE_AUDIO_PRESETS,
@@ -0,0 +1,95 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex adapter discovery and manifest contract."""
import json
from pathlib import Path
import pytest
from fastvideo.pipelines.basic.pika_h3_flex import adapter
def _manifest(task: str = "fl2va") -> dict:
return {
"format_version": 1,
"model": "pika-flex",
"task": task,
"release_id": "pika-flex-fl2va-v1",
"rank": 128,
"alpha": 128,
"trunk": "transformer_ref" if task == "ref2va" else "transformer",
"sampling": {
"knots": [1000, 750, 500, 250, 100, 40, 0],
"video_shift": 12.0,
"audio_shift": 3.0,
"method": "renoise",
},
"base": {"repo_id": "MiniMaxAI/MiniMax-H3", "revision": "42ed227ee7df40d41602854ae760620d6eb651fe"},
"target_modules": ["to_q", "to_k", "to_v", "to_out.0", "ff.net.0.proj", "ff.net.2"],
"sha256": "0" * 64,
}
def _write_release(root: Path, task: str = "fl2va") -> Path:
task_dir = root / task
task_dir.mkdir(parents=True)
(task_dir / "adapter.safetensors").write_bytes(b"")
(task_dir / "manifest.json").write_text(json.dumps(_manifest(task)), encoding="utf-8")
return task_dir
def test_manifest_matches_released_contract() -> None:
adapter.validate_adapter_manifest(_manifest(), task="fl2va")
adapter.validate_adapter_manifest(_manifest("ref2va"), task="ref2va")
@pytest.mark.parametrize(
("field", "value", "message"),
[
("rank", 64, "rank"),
("alpha", 64, "alpha"),
("trunk", "transformer_ref", "trunk"),
("task", "ref2va", "task"),
("format_version", 2, "format"),
],
)
def test_manifest_rejects_wrong_release(field: str, value: object, message: str) -> None:
manifest = _manifest()
manifest[field] = value
with pytest.raises(adapter.PikaAdapterError, match=message):
adapter.validate_adapter_manifest(manifest, task="fl2va")
def test_manifest_rejects_a_different_sampler() -> None:
manifest = _manifest()
manifest["sampling"]["knots"] = [1000, 500, 0]
with pytest.raises(adapter.PikaAdapterError, match="sampling knots"):
adapter.validate_adapter_manifest(manifest, task="fl2va")
def test_load_adapter_finds_the_task_directory(tmp_path: Path) -> None:
task_dir = _write_release(tmp_path)
loaded = adapter.load_adapter(tmp_path, task="fl2va")
assert loaded.path == str(task_dir / "adapter.safetensors")
assert loaded.trunk == "transformer"
assert loaded.manifest["task"] == "fl2va"
def test_load_adapter_finds_a_task_directory_or_adapter_file(tmp_path: Path) -> None:
task_dir = _write_release(tmp_path)
assert adapter.load_adapter(task_dir, task="fl2va").path == str(task_dir / "adapter.safetensors")
assert adapter.load_adapter(task_dir / "adapter.safetensors", task="fl2va").path == str(
task_dir / "adapter.safetensors")
def test_load_adapter_rejects_a_missing_task(tmp_path: Path) -> None:
_write_release(tmp_path, "fl2va")
with pytest.raises(adapter.PikaAdapterError, match="ref2va"):
adapter.load_adapter(tmp_path, task="ref2va")
def test_load_adapter_requires_the_manifest(tmp_path: Path) -> None:
task_dir = _write_release(tmp_path)
(task_dir / "manifest.json").unlink()
with pytest.raises(adapter.PikaAdapterError, match="manifest"):
adapter.load_adapter(tmp_path, task="fl2va")
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex bucket geometry contract."""
import pytest
from fastvideo.pipelines.basic.pika_h3_flex import buckets
def test_released_bucket_tables_match_pika() -> None:
assert buckets.bucket_table("p480") == (
(960, 416),
(832, 480),
(704, 480),
(640, 480),
(608, 480),
(480, 480),
(480, 608),
(480, 640),
(480, 704),
(480, 832),
(416, 960),
)
assert buckets.bucket_table("p768") == (
(1536, 672),
(1344, 768),
(1152, 768),
(1024, 768),
(960, 768),
(768, 768),
(768, 960),
(768, 1024),
(768, 1152),
(768, 1344),
(672, 1536),
)
def test_unset_canvas_uses_default_tier_and_aspect() -> None:
assert buckets.resolve_canvas(None, None) == (768, 1344, "p768", "16:9")
@pytest.mark.parametrize(
("height", "width", "expected"),
[
(720, 1280, (768, 1344, "p768", "16:9")),
(1080, 1920, (768, 1344, "p768", "16:9")),
(480, 832, (480, 832, "p480", "16:9")),
(1024, 768, (1024, 768, "p768", "3:4")),
(960, 416, (960, 416, "p480", "9:21")),
],
)
def test_requested_canvas_snaps_to_bucket(height: int, width: int, expected: tuple[int, int, str, str]) -> None:
assert buckets.resolve_canvas(height, width) == expected
def test_one_sided_canvas_is_rejected() -> None:
with pytest.raises(ValueError, match="together"):
buckets.resolve_canvas(720, None)
def test_tier_reference_short_edge_follows_the_output_canvas() -> None:
assert buckets.reference_short_edge_for_canvas(480, 832) == 1024
assert buckets.reference_short_edge_for_canvas(768, 1344) == 2048
def test_resolve_frames_aligns_to_the_17n_plus_5_grid() -> None:
assert buckets.resolve_frames(120) == 124
assert buckets.resolve_frames(124) == 124
assert buckets.resolve_frames(345) == 345
assert buckets.resolve_frames(360) == 362
with pytest.raises(ValueError, match="Pika H3 Flex generates"):
buckets.resolve_frames(107)
def test_unknown_tier_is_rejected() -> None:
with pytest.raises(ValueError, match="released 480p and 768p"):
buckets.resolve_tier("1080p")
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: Apache-2.0
"""Pika H3 Flex's re-noise scheduler step."""
import torch
from fastvideo.models.schedulers.scheduling_minimax_h3 import MiniMaxH3Scheduler
def _scheduler() -> MiniMaxH3Scheduler:
scheduler = MiniMaxH3Scheduler(shift=12.0, stochastic_sampling=True, shift_provided_sigmas=True)
scheduler.set_timesteps(sigmas=[1.0, 0.5, 0.0], device="cpu")
scheduler.set_noise_generator(torch.Generator().manual_seed(7))
return scheduler
def test_renoised_step_uses_the_next_sigma_and_fresh_noise() -> None:
scheduler = _scheduler()
sample = torch.zeros(2, 3)
velocity = torch.ones(2, 3)
timestep = scheduler.timesteps[0]
output = scheduler.step(velocity, timestep, sample).prev_sample
expected_noise = torch.randn(sample.shape, generator=torch.Generator().manual_seed(7), dtype=torch.float32)
torch.testing.assert_close(output, 0.5 * torch.ones_like(sample) + 0.5 * expected_noise)
def test_terminal_renoised_step_returns_x0_without_drawing_noise() -> None:
scheduler = _scheduler()
_ = scheduler.step(torch.ones(2, 3), scheduler.timesteps[0], torch.zeros(2, 3))
state_before = scheduler._noise_generator.get_state()
output = scheduler.step(torch.ones(2, 3), scheduler.timesteps[1], torch.zeros(2, 3)).prev_sample
state_after = scheduler._noise_generator.get_state()
torch.testing.assert_close(output, torch.full((2, 3), 0.5))
assert torch.equal(state_before, state_after)
def test_euler_step_is_unchanged_when_stochastic_sampling_is_off() -> None:
scheduler = MiniMaxH3Scheduler(shift=12.0)
scheduler.set_timesteps(sigmas=[1.0, 0.5, 0.0], device="cpu")
sample = torch.full((2, 3), 2.0)
velocity = torch.full((2, 3), 4.0)
output = scheduler.step(velocity, scheduler.timesteps[0], sample).prev_sample
# x0 = 2 + 1 * 4 = 6; ratio = 0.5, so prev = 0.5 * 2 + 0.5 * 6 = 4.
torch.testing.assert_close(output, torch.full((2, 3), 4.0))