Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
291 lines
15 KiB
Python
291 lines
15 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Reference-conditioned FastH3 OmniRef (Ref2VA) generation on Apple Silicon MLX.
|
|
|
|
A FastH3 OmniRef PDD export carries only what distillation changed:
|
|
``transformer_ref/``, the two scheduler configs and ``fastvideo_inference.json``.
|
|
The text encoder, tokenizer, processor and both VAEs are base MiniMax-H3's, so
|
|
this script composes the two into one directory of symlinks - the same shape the
|
|
CUDA example builds - and then runs the MLX runtime over it.
|
|
|
|
There is no FP4 on Apple Silicon, so the DiT converts to INT8 (or INT6/INT4),
|
|
the same formats the text-to-video-audio MLX path uses. The DiT has to be in
|
|
the packed MLX layout first:
|
|
|
|
python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py \\
|
|
--model-root <composed>/transformer_ref --out ~/models/FastH3-OmniRef-MLX \\
|
|
--formats int8 --include-vsa
|
|
|
|
python examples/inference/basic/mlx_fasth3_omniref.py \\
|
|
--export ./FastVideo-FastH3-Omni-8-Step-V1 --composed-dir ./FastH3-OmniRef-composed \\
|
|
--base-model-path ./MiniMax-H3 --mlx-checkpoint ~/models/FastH3-OmniRef-MLX/int8 \\
|
|
--image reference.png --audio voice.wav \\
|
|
--prompt "a person on a sofa, lit by a warm lamp, speaking to camera" \\
|
|
--output-path ~/fasth3_outputs/omniref.mp4
|
|
|
|
References are ordered: pass them in the order the model should read them, with
|
|
``--image`` / ``--video`` / ``--audio`` (for example ``--video dance.mp4
|
|
--image outfit.png --audio voice.wav``). At least one image or video reference
|
|
is required; audio references only accompany them.
|
|
|
|
Add ``--compose-only`` to build the composed directory (the conversion above
|
|
needs it) and stop, with no checkpoint, prompt or reference.
|
|
|
|
The PDD ladder comes from the export's contract, so ``--steps`` is optional:
|
|
without it the trained fused-block count runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CONTRACT = "fastvideo_inference.json"
|
|
MANIFESTS = ("modular_model_index.json", "model_index.json")
|
|
EXPORT_COMPONENTS = ("transformer_ref", "scheduler", "audio_scheduler")
|
|
BASE_COMPONENTS = ("text_encoder", "tokenizer", "processor", "vae", "audio_vae")
|
|
|
|
|
|
class _AppendReference(argparse.Action):
|
|
"""Collect --image/--video/--audio into one list that keeps command-line order."""
|
|
|
|
def __call__(self, parser, namespace, value, option_string=None):
|
|
references = list(getattr(namespace, self.dest, None) or [])
|
|
references.append((self.const, value))
|
|
setattr(namespace, self.dest, references)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--export", type=Path,
|
|
help="FastH3 OmniRef PDD export directory; composes it with --base-model-path")
|
|
parser.add_argument("--base-model-path", type=Path,
|
|
help="base MiniMax-H3 snapshot (vae/, audio_vae/, text_encoder/, tokenizer/)")
|
|
parser.add_argument("--composed-dir", type=Path, default=Path("outputs/fasth3_omniref_mlx_model"),
|
|
help="where the composed directory of symlinks is written")
|
|
parser.add_argument("--model-root", type=Path,
|
|
help="an already-composed directory; skips composing")
|
|
parser.add_argument("--compose-only", action="store_true",
|
|
help="compose --export with --base-model-path into --composed-dir and exit")
|
|
parser.add_argument("--mlx-checkpoint", type=Path,
|
|
help="packed MLX checkpoint converted from the composed transformer_ref/")
|
|
for flag in ("image", "video", "audio"):
|
|
parser.add_argument(f"--{flag}", dest="references", action=_AppendReference, const=flag, metavar="PATH",
|
|
help=f"an ordered {flag} reference (repeatable)")
|
|
parser.add_argument("--prompt")
|
|
parser.add_argument("--output-path", type=Path, default=Path("outputs/mlx_fasth3_omniref.mp4"))
|
|
parser.add_argument("--height", type=int, default=480)
|
|
parser.add_argument("--width", type=int, default=832)
|
|
parser.add_argument("--num-frames", type=int, default=124)
|
|
parser.add_argument("--seed", type=int, default=2026)
|
|
parser.add_argument("--steps", type=int, default=0,
|
|
help="transformer forwards; 0 uses the export contract's trained count")
|
|
parser.add_argument("--prompt-cache-dir", type=Path)
|
|
parser.add_argument("--metal-wired-limit-gib", type=float,
|
|
help="keep this much unified memory wired so macOS does not page the DiT out mid-denoise")
|
|
parser.add_argument("--mlx-cache-limit-gib", type=float,
|
|
help="bound MLX's freed-buffer cache; unbounded by default")
|
|
parser.add_argument("--quant-gemm", choices=("auto", "dequant", "qmm"), default=None,
|
|
help="INT8/INT6/INT4 linear path: auto (measured default), dequant (dequantize + dense "
|
|
"GEMM for wide inputs), qmm (mx.quantized_matmul). Unset defers to "
|
|
"FASTVIDEO_MLX_QUANT_GEMM. Ignored by a bf16 checkpoint.")
|
|
parser.add_argument("--reference-max-pixels", default=None,
|
|
help="opt-in cap on each image/video reference canvas (down only): a pixel count, or "
|
|
"'match' for the clip's own area. Faster, but references lose detail versus the trained "
|
|
"canvas.")
|
|
parser.add_argument("--vae-dtype", default="fp32")
|
|
parser.add_argument("--vae-decoder", type=Path,
|
|
help="standalone video decoder (FastH3 decoder[-nvfp4].safetensors or a LightVAE decoder) "
|
|
"replacing the base vae/ decoder")
|
|
parser.add_argument("--vae-encoder", type=Path,
|
|
help="standalone video encoder (LightVAE) replacing the base vae/ encoder; with both flags "
|
|
"vae/ only needs config.json")
|
|
parser.add_argument("--vae-decoder-quantization", choices=("nvfp4", "mxfp8", "int8", "int4"),
|
|
help="quantize the decoder block linears of a dense decoder file as it loads")
|
|
parser.add_argument("--vsa", action=argparse.BooleanOptionalAction, default=None,
|
|
help="VSA sparse attention (needs a checkpoint converted with --include-vsa). "
|
|
"On by default when the contract is a VSA contract; --no-vsa runs dense.")
|
|
parser.add_argument("--vsa-sparsity", type=float, default=None,
|
|
help="generated-video sparsity; default is the contract's trained value")
|
|
parser.add_argument("--vsa-tile-size", type=int, default=None,
|
|
help="VSA tile: 64, 128 or 256. Must equal the contract's trained tile; "
|
|
"default is the contract's.")
|
|
parser.add_argument("--vsa-ref-keep-rate", type=float, default=None,
|
|
help="fraction of each reference video's tiles a query keeps (p2_multi_region); "
|
|
"default is the contract's trained value")
|
|
parser.add_argument("--vsa-impl", choices=("auto", "reference", "simd", "sdpa", "nax"), default="auto",
|
|
help="sparse attention backend; auto uses the SIMD Metal kernel for 128-token tiles and "
|
|
"gather+SDPA otherwise, reference forces gather+SDPA; sdpa shares K/V with a tile mask "
|
|
"and nax uses M5 TensorOps over selected blocks (both opt-in, incompatible with --reuse-prefix-kv)")
|
|
parser.add_argument("--reuse-prefix-kv", action="store_true",
|
|
help="opt-in, lossy: record the text/reference rows' per-block K/V on step 1 and run later "
|
|
"steps on the target audio/video rows only (faster, slightly different output)")
|
|
parser.add_argument("--prefix-kv-refresh-every", type=int, default=0,
|
|
help="re-record the prefix K/V with a full forward every N steps (0 never)")
|
|
parser.add_argument("--prefix-kv-cache-max-gib", type=float, default=4.0,
|
|
help="refuse prefix K/V reuse when the estimated cache exceeds this many GiB")
|
|
parser.add_argument("--prefix-kv-storage", choices=("bf16", "fp16", "int8"), default="bf16",
|
|
help="prefix K/V cache storage dtype (int8 halves the cache)")
|
|
parser.add_argument("--inter-step-cooldown-s", type=float, default=0.0)
|
|
return parser
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
if args.compose_only:
|
|
if args.export is None or args.base_model_path is None:
|
|
parser.error("--compose-only needs --export and --base-model-path")
|
|
return args
|
|
if args.mlx_checkpoint is None or args.prompt is None:
|
|
parser.error("--mlx-checkpoint and --prompt are required unless --compose-only is set")
|
|
if not args.references:
|
|
parser.error("pass at least one ordered reference with --image, --video, or --audio")
|
|
if not any(kind != "audio" for kind, _ in args.references):
|
|
parser.error("Ref2VA needs at least one image or video reference")
|
|
if args.model_root is None and (args.export is None or args.base_model_path is None):
|
|
parser.error("give --model-root, or --export together with --base-model-path")
|
|
if args.vsa_impl in ("sdpa", "nax") and args.reuse_prefix_kv:
|
|
parser.error(f"--vsa-impl {args.vsa_impl} does not support --reuse-prefix-kv")
|
|
return args
|
|
|
|
|
|
def _link(destination: Path, source: Path) -> None:
|
|
if not source.exists():
|
|
raise FileNotFoundError(f"Missing checkpoint component: {source}")
|
|
source = source.resolve()
|
|
if destination.is_symlink():
|
|
if destination.resolve() == source:
|
|
return
|
|
destination.unlink()
|
|
elif destination.exists():
|
|
raise FileExistsError(f"{destination} exists and is not a symlink; choose another --composed-dir.")
|
|
destination.symlink_to(source, target_is_directory=source.is_dir())
|
|
|
|
|
|
def compose_model_dir(export_dir: Path, base_dir: Path, composed_dir: Path) -> Path:
|
|
"""One MiniMax-H3 model directory: distilled components from the export, the rest from the base."""
|
|
contract = export_dir / CONTRACT
|
|
if not contract.is_file():
|
|
raise FileNotFoundError(f"{export_dir} has no {CONTRACT}; it is not a FastH3 distilled export.")
|
|
composed_dir.mkdir(parents=True, exist_ok=True)
|
|
for name in (*EXPORT_COMPONENTS, CONTRACT):
|
|
_link(composed_dir / name, export_dir / name)
|
|
for name in MANIFESTS:
|
|
for root in (export_dir, base_dir):
|
|
if (root / name).is_file():
|
|
_link(composed_dir / name, root / name)
|
|
break
|
|
for name in BASE_COMPONENTS:
|
|
_link(composed_dir / name, base_dir / name)
|
|
return composed_dir
|
|
|
|
|
|
def load_contract(export_dir: Path) -> dict[str, Any]:
|
|
"""The export's PDD contract, validated as a Ref2VA one."""
|
|
path = export_dir / CONTRACT
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"{export_dir} has no {CONTRACT}")
|
|
contract = json.loads(path.read_text(encoding="utf-8"))
|
|
if contract.get("model_type") != "ref2va":
|
|
raise ValueError(f"{path} is not a Ref2VA contract; use the example matching the checkpoint.")
|
|
return contract
|
|
|
|
|
|
def resolve_reference_max_pixels(value: str | None, width: int, height: int) -> int | None:
|
|
"""``--reference-max-pixels``: unset keeps the trained canvas; 'match' uses the clip's area."""
|
|
if value is None:
|
|
return None
|
|
if value == "match":
|
|
return width * height
|
|
pixels = int(value)
|
|
if pixels <= 0:
|
|
raise ValueError(f"--reference-max-pixels must be positive or 'match', got {value!r}.")
|
|
return pixels
|
|
|
|
|
|
def resolve_model_root(args: argparse.Namespace) -> Path:
|
|
if args.model_root is not None:
|
|
return args.model_root
|
|
return compose_model_dir(args.export, args.base_model_path, args.composed_dir)
|
|
|
|
|
|
def run(args: argparse.Namespace) -> None:
|
|
if args.compose_only:
|
|
print(f"Composed model directory: {resolve_model_root(args)}")
|
|
return
|
|
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline
|
|
from fastvideo.mlx_runtime.minimax_h3_reference import MiniMaxH3Reference
|
|
|
|
model_root = resolve_model_root(args)
|
|
export_dir = args.export if args.export is not None else model_root
|
|
contract = load_contract(export_dir)
|
|
if args.export is not None:
|
|
print(f"Composed model directory: {model_root}")
|
|
print(f"Contract: {contract['num_inference_steps']} fused blocks of a {contract['pdd_steps']}-interval grid, "
|
|
f"{contract['attention_backend']} with sparsity {contract['vsa_sparsity']}, "
|
|
f"{contract['vsa_tile_size']}-token tiles")
|
|
references = [MiniMaxH3Reference(source=path, media_type=kind) for kind, path in args.references]
|
|
args.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
pipeline = MiniMaxH3MLXPipeline(
|
|
model_root=model_root,
|
|
mlx_dit_checkpoint=args.mlx_checkpoint,
|
|
vae_dtype=args.vae_dtype,
|
|
vae_decoder_path=args.vae_decoder,
|
|
vae_encoder_path=args.vae_encoder,
|
|
vae_decoder_quantization=args.vae_decoder_quantization,
|
|
prompt_cache_dir=args.prompt_cache_dir,
|
|
metal_wired_limit_gib=args.metal_wired_limit_gib,
|
|
mlx_cache_limit_gib=args.mlx_cache_limit_gib,
|
|
quant_gemm=args.quant_gemm,
|
|
)
|
|
try:
|
|
kwargs: dict[str, Any] = {}
|
|
if args.steps:
|
|
kwargs["num_steps"] = args.steps
|
|
elif "transformer_forwards" in contract:
|
|
# The contract's fused-block count is the ladder the student was distilled on.
|
|
kwargs["num_steps"] = int(contract["transformer_forwards"])
|
|
result = pipeline.generate(
|
|
args.prompt,
|
|
output_path=args.output_path,
|
|
references=references,
|
|
height=args.height,
|
|
width=args.width,
|
|
num_frames=args.num_frames,
|
|
seed=args.seed,
|
|
vsa=args.vsa,
|
|
vsa_sparsity=args.vsa_sparsity,
|
|
vsa_tile_size=args.vsa_tile_size,
|
|
vsa_ref_keep_rate=args.vsa_ref_keep_rate,
|
|
vsa_impl=args.vsa_impl,
|
|
inter_step_cooldown_s=args.inter_step_cooldown_s,
|
|
reuse_prefix_kv=args.reuse_prefix_kv,
|
|
prefix_kv_refresh_every=args.prefix_kv_refresh_every,
|
|
prefix_kv_cache_max_gib=args.prefix_kv_cache_max_gib,
|
|
prefix_kv_storage=args.prefix_kv_storage,
|
|
reference_max_pixels=resolve_reference_max_pixels(args.reference_max_pixels, args.width, args.height),
|
|
**kwargs,
|
|
)
|
|
finally:
|
|
pipeline.close()
|
|
print(json.dumps({
|
|
"video_path": result.video_path,
|
|
"references": [kind for kind, _ in args.references],
|
|
"timings_s": {key: round(value, 2) for key, value in result.timings.items()},
|
|
"peak_memory_gib": {key: round(value, 2) for key, value in result.peak_memory_gib.items()},
|
|
"vsa": result.vsa,
|
|
"prefix_kv": result.prefix_kv,
|
|
"audio_samples": int(result.waveform.shape[-1]),
|
|
}, indent=2))
|
|
|
|
|
|
def main() -> None:
|
|
run(parse_args())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|