Compare commits

...
Author SHA1 Message Date
Ishan Vaish 56ef6241d4 [feat] Add FastMetal 14B and 5B MLX serving, harden test coverage 2026-09-01 05:48:10 +00:00
Ishan Vaish 247972271c [feat] Serve FastMetal (Wan2.1) 1.3B through native MLX 2026-09-01 05:48:10 +00:00
Ishan Vaish d74475ce06 [feat] Let the OpenAI server plug in a non-CUDA generator 2026-09-01 05:48:10 +00:00
Shahrad ZomorrodiandShahrad Zomorrodi 1ae39562dd [bugfix] Write generated documentation as UTF-8 (#1797)
Co-authored-by: Shahrad Zomorrodi <264690209+shahradzomorrodi@users.noreply.github.com>
2026-08-31 22:45:03 -07:00
Aryan KumarandAryan Kumar 26064193e2 [feat] Add an H3 server cookbook and prompt playground (#1798)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 22:14:33 -07:00
William Lin 8446fc003e [docs]: add FastH3 Preview v1 news links (#1804) 2026-08-31 22:12:02 -07:00
Aryan KumarandAryan Kumar a28f2bab4b [feat] Add an optional MLX TAEH3 preview decoder (#1794)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 05:08:06 -07:00
Aryan KumarandAryan Kumar f82d8be4bf [perf] Sequential MiniMax H3 start with GPU-direct DiT load (#1793)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 04:20:21 -07:00
Aryan KumarandAryan Kumar 8e1775183e [perf] Speed up exact MiniMax H3 MLX inference (#1792)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 04:14:55 -07:00
Aryan KumarandAryan Kumar 620bc36dc4 [docs]: Cookbook catalog improvements (#1790)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 17:50:57 -07:00
Suhaan Khurana 29ff16ec96 [feat] Add MiniMax H3 MLX spatial fast mode (#1789) 2026-08-30 17:47:02 -07:00
Aryan KumarandAryan Kumar 8f9d76a80d [perf]: dispatch wide-M affine H3 MLX linears through dequant plus dense GEMM (#1788)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 14:42:01 -07:00
a4d9a75e2c [perf] Add MiniMax H3 MLX VSA and SIMD attention (#1776)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-30 13:44:54 -07:00
KyleNeverGivesUp b2db0c0a13 [ci]: seed stable GB10 grad-norm references (#1756) 2026-08-30 03:09:18 -07:00
Kevin Lin 6aa7d8a278 [misc] FastVideo Studio UI Additions (H3 Ref2V support) (#1783) 2026-08-30 03:06:47 -07:00
Aryan KumarandAryan Kumar ccc9014430 [docs] Add model-family inference cookbook (#1787)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 02:26:10 -07:00
121 changed files with 14487 additions and 386 deletions
+18 -2
View File
@@ -8,6 +8,8 @@ on:
- "fastvideo/mlx_runtime/**"
- "fastvideo/tests/mlx/**"
- "fastvideo/tests/platforms/test_mps_vsa_error.py"
- "fastvideo/tests/platforms/test_cpu_sdpa.py"
- "fastvideo/platforms/cpu.py"
- "fastvideo/platforms/mps.py"
- "fastvideo/platforms/__init__.py"
- "fastvideo/__init__.py"
@@ -79,6 +81,12 @@ jobs:
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
@@ -91,7 +99,8 @@ jobs:
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
fastvideo/tests/platforms/test_cpu_sdpa.py \
-v -s -o faulthandler_timeout=120
# Same tests on MLX's CPU backend. Hosted macOS runners are scarce and
# slower to schedule; this Linux job gives fast PR signal on the identical
@@ -136,6 +145,12 @@ jobs:
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
@@ -148,4 +163,5 @@ jobs:
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
fastvideo/tests/platforms/test_cpu_sdpa.py \
-v -s -o faulthandler_timeout=120
+5
View File
@@ -55,6 +55,8 @@ eggs/
# MkDocs documentation
site/
docs/assets/cookbook-serving.json
examples/serving/clients/node_modules/
docs/getting_started/examples/
docs/examples/
docs/inference/examples/
@@ -133,6 +135,9 @@ fastvideo/tests/ssim/reference_videos/**
!fastvideo/tests/ssim/reference_videos/**/*.mp4
!fastvideo/tests/ssim/reference_videos/**/*.png
# Local H3 MLX kernel / exactness benches (JSON, logs, frames, videos)
.kernel_bench/
# Editor logs and local Python version pins (accidentally committed)
*.nvimlog
.nvimlog
+1 -1
View File
@@ -9,7 +9,7 @@
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
## NEWS
- `2026/08/23`: [FastH3 Preview v0.2](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) is a 4-step DMD2-distilled MiniMax-H3 checkpoint that generates synchronized video and audio. Run the verified [basic FastH3 example](examples/inference/basic/basic_fasth3.py), see the [inference guide](examples/inference/basic/README.md#fasth3-preview), or have a coding agent install FastVideo with the [agent setup prompt](#install-with-an-ai-coding-agent).
- `2026/08/27`: [FastH3 Preview v1](https://haoailab.com/blogs/fasth3-preview/) is an open-weight 4-step sparse-distilled MiniMax-H3 model for synchronized video-and-audio generation, developed in collaboration with [Nuva Lab](https://nuvalab.ai/) and the [NVIDIA FastGen team](https://github.com/NVlabs/FastGen). Download the recommended [VSA / Data-Free weights](https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree), or see the [full FastH3 collection](https://huggingface.co/collections/FastVideo/fastvideo-fasth3).
- `2026/08/19`: FastVideo now supports MLX on Apple Silicon with [FastMetal-QAD](https://huggingface.co/collections/FastVideo/fastmetal), a family of 1.3B, 5B, and 14B models optimized for Mac—follow the [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/) and read the [Blog](https://haoailab.com/blogs/fastmetal/).
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. See the [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), [Attn-QAT training guide](https://haoailab.com/FastVideo/training/attn_qat/), and [blog](https://haoailab.com/blogs/fastwan-qad/).
- `2026/03/17`: Release demo: Into the Dreamverse: Vibe Directing in FastVideo, check out the [Blog](https://haoailab.com/blogs/dreamverse/).
+20 -4
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import contextlib
import logging
import json
import sqlite3
import threading
from pathlib import Path
@@ -46,8 +47,13 @@ DEFAULT_SETTINGS: dict[str, Any] = {
def _sqlite_row_get(row: sqlite3.Row, key: str, default: Any) -> Any:
"""Like dict.get for sqlite3.Row (Row has no .get on Python 3.10)."""
return row[key] if key in row else default # noqa: SIM401
"""Like dict.get for sqlite3.Row (Row has no .get on Python 3.10).
NOTE: `key in row` tests Row *values*, not column names, so the membership
check has to go through .keys() -- otherwise every lookup falls back to the
default and jobs restored from the database lose their stored fields.
"""
return row[key] if key in row.keys() else default # noqa: SIM401, SIM118
def _get_db_path(data_dir: Path) -> Path:
@@ -83,6 +89,9 @@ def _migrate_db(conn: sqlite3.Connection) -> None:
_add_column_if_missing(conn, "jobs", "fps", "INTEGER", "24")
_add_column_if_missing(conn, "jobs", "workload_type", "TEXT", "'t2v'")
_add_column_if_missing(conn, "jobs", "image_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "name", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "last_image_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "references_json", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "job_type", "TEXT", "'inference'")
_add_column_if_missing(conn, "jobs", "data_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "max_train_steps", "INTEGER", "1000")
@@ -242,7 +251,8 @@ class Database:
self._execute(
"""
INSERT INTO jobs (
id, model_id, prompt, workload_type, image_path, job_type, status,
id, model_id, name, prompt, workload_type, image_path,
last_image_path, references_json, job_type, status,
created_at, started_at, finished_at, error, output_path, log_file_path,
num_inference_steps, num_frames, height, width, guidance_scale,
guidance_rescale, fps, seed, num_gpus, dit_cpu_offload,
@@ -254,14 +264,17 @@ class Database:
dmd_use_vsa, dmd_vsa_sparsity, dmd_denoising_steps,
real_score_guidance_scale,
generator_update_interval, real_score_model_path, fake_score_model_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job["id"],
job["model_id"],
job.get("name", ""),
job["prompt"],
job.get("workload_type", "t2v"),
job.get("image_path", ""),
job.get("last_image_path", ""),
json.dumps(job.get("references") or []),
job.get("job_type", "inference"),
job["status"],
job["created_at"],
@@ -540,9 +553,12 @@ def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
result = {
"id": row["id"],
"model_id": row["model_id"],
"name": _sqlite_row_get(row, "name", "") or "",
"prompt": row["prompt"],
"workload_type": _sqlite_row_get(row, "workload_type", "t2v"),
"image_path": _sqlite_row_get(row, "image_path", "") or "",
"last_image_path": _sqlite_row_get(row, "last_image_path", "") or "",
"references": _sqlite_row_get(row, "references_json", "") or "",
"job_type": _sqlite_row_get(row, "job_type", "inference"),
"status": row["status"],
"created_at": row["created_at"],
+239 -58
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import atexit
import collections
import contextlib
import copy
import enum
import json
import logging
import logging.handlers
import multiprocessing as mp
@@ -123,10 +125,13 @@ class LogBufferHandler(logging.Handler):
class Job:
id: str
model_id: str
prompt: str
name: str = ""
prompt: str = ""
workload_type: str = "t2v"
job_type: str = "inference"
image_path: str = ""
last_image_path: str = ""
references: list[dict[str, Any]] = field(default_factory=list)
status: JobStatus = JobStatus.PENDING
created_at: float = field(default_factory=time.time)
started_at: float | None = None
@@ -145,6 +150,7 @@ class Job:
negative_prompt: str = ""
num_gpus: int = 1
dit_cpu_offload: bool = False
dit_layerwise_offload: bool = False
text_encoder_cpu_offload: bool = False
vae_cpu_offload: bool = False
image_encoder_cpu_offload: bool = False
@@ -180,10 +186,13 @@ class Job:
return {
"id": self.id,
"model_id": self.model_id,
"name": self.name,
"prompt": self.prompt,
"workload_type": self.workload_type,
"job_type": self.job_type,
"image_path": self.image_path,
"last_image_path": self.last_image_path,
"references": self.references,
"status": self.status.value,
"created_at": self.created_at,
"started_at": self.started_at,
@@ -202,6 +211,7 @@ class Job:
"negative_prompt": self.negative_prompt,
"num_gpus": self.num_gpus,
"dit_cpu_offload": self.dit_cpu_offload,
"dit_layerwise_offload": self.dit_layerwise_offload,
"text_encoder_cpu_offload": self.text_encoder_cpu_offload,
"vae_cpu_offload": self.vae_cpu_offload,
"image_encoder_cpu_offload": self.image_encoder_cpu_offload,
@@ -232,6 +242,75 @@ class Job:
}
MINIMAX_H3_REF2VA_PIPELINE = "MiniMaxH3Ref2VAModularPipeline"
def _build_h3_references(raw: list[dict[str, Any]]) -> list[Any]:
"""Turn the API's reference dicts into MiniMaxH3Reference objects.
Imported lazily so the API server starts without pulling in fastvideo.
"""
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
built = []
for i, ref in enumerate(raw):
source = (ref or {}).get("source")
if not source:
raise ValueError(f"reference {i} has no source")
if not os.path.isfile(source):
raise ValueError(f"reference {i} source not found: {source}")
kwargs: dict[str, Any] = {
"source": source,
"media_type": (ref.get("media_type") or "image"),
}
for opt in ("soundtrack", "fps", "sample_rate"):
if ref.get(opt) not in (None, ""):
kwargs[opt] = ref[opt]
built.append(MiniMaxH3Reference(**kwargs))
return built
JOB_LOG_FILENAME = "out.log"
def _job_log_path(output_dir: str, job_id: str) -> str:
"""Each job's log lives beside its outputs: <output_dir>/<job_id>/out.log."""
return os.path.join(output_dir, job_id, JOB_LOG_FILENAME)
def _decode_references(value: Any) -> list[dict[str, Any]]:
"""Reference lists round-trip through the DB as JSON text."""
if not value:
return []
if isinstance(value, list):
return list(value)
try:
decoded = json.loads(value)
except (TypeError, ValueError):
logger.warning("Could not decode stored references: %r", value)
return []
return list(decoded) if isinstance(decoded, list) else []
def _generator_is_alive(generator: Any) -> bool:
"""True if the generator's worker processes are all still running.
A cached VideoGenerator holds a MultiprocExecutor whose workers are separate
processes; nothing notices when they exit. Probing `proc.is_alive()` is what
the executor itself uses during shutdown. Anything unexpected in the object
graph is treated as alive so a probe failure can never wedge the cache.
"""
executor = getattr(generator, "executor", None)
workers = getattr(executor, "workers", None)
if not workers:
return True
try:
return all(w.proc.is_alive() for w in workers)
except Exception:
logger.debug("Worker liveness probe failed", exc_info=True)
return True
class JobRunner:
"""Manages video generation jobs, their execution, and generator caching."""
@@ -276,7 +355,7 @@ class JobRunner:
"""Populate job's log buffer from its log file if it exists."""
path = job.log_file_path
if not path:
path = os.path.join(self.log_dir, f"{job.id}.log")
path = _job_log_path(self.output_dir, job.id)
if not os.path.isfile(path):
return
try:
@@ -314,10 +393,13 @@ class JobRunner:
job = Job(
id=row["id"],
model_id=row["model_id"],
name=row.get("name", "") or "",
prompt=row["prompt"],
workload_type=row.get("workload_type", "t2v"),
job_type=row.get("job_type", "inference"),
image_path=row.get("image_path", "") or "",
last_image_path=row.get("last_image_path", "") or "",
references=_decode_references(row.get("references")),
data_path=row.get("data_path", "") or "",
max_train_steps=row.get("max_train_steps", 1000),
train_batch_size=row.get("train_batch_size", 1),
@@ -350,6 +432,7 @@ class JobRunner:
negative_prompt=row.get("negative_prompt", "") or "",
num_gpus=row.get("num_gpus", 1),
dit_cpu_offload=row.get("dit_cpu_offload", False),
dit_layerwise_offload=row.get("dit_layerwise_offload", False),
text_encoder_cpu_offload=row.get("text_encoder_cpu_offload", False),
vae_cpu_offload=row.get("vae_cpu_offload", False),
image_encoder_cpu_offload=row.get("image_encoder_cpu_offload", False),
@@ -394,9 +477,12 @@ class JobRunner:
job_id: str,
model_id: str,
prompt: str,
name: str = "",
workload_type: str = "t2v",
job_type: str = "inference",
image_path: str = "",
last_image_path: str = "",
references: list[dict[str, Any]] | None = None,
data_path: str = "",
max_train_steps: int = 1000,
train_batch_size: int = 1,
@@ -422,6 +508,7 @@ class JobRunner:
num_gpus: int = 1,
negative_prompt: str = "",
dit_cpu_offload: bool = False,
dit_layerwise_offload: bool = False,
text_encoder_cpu_offload: bool = False,
vae_cpu_offload: bool = False,
image_encoder_cpu_offload: bool = False,
@@ -435,10 +522,13 @@ class JobRunner:
job = Job(
id=job_id,
model_id=model_id,
name=(name or "").strip(),
prompt=prompt.strip(),
workload_type=workload_type or "t2v",
job_type=job_type or "inference",
image_path=image_path or "",
last_image_path=last_image_path or "",
references=list(references or []),
data_path=data_path or "",
max_train_steps=max_train_steps,
train_batch_size=train_batch_size,
@@ -464,6 +554,7 @@ class JobRunner:
negative_prompt=negative_prompt or "",
num_gpus=num_gpus,
dit_cpu_offload=dit_cpu_offload,
dit_layerwise_offload=dit_layerwise_offload,
text_encoder_cpu_offload=text_encoder_cpu_offload,
vae_cpu_offload=vae_cpu_offload,
image_encoder_cpu_offload=image_encoder_cpu_offload,
@@ -521,6 +612,84 @@ class JobRunner:
logger.info("Deleted job %s", job.id)
return True
CONFIG_FIELDS: tuple[str, ...] = (
"model_id",
"name",
"prompt",
"workload_type",
"job_type",
"image_path",
"last_image_path",
"references",
"negative_prompt",
"num_inference_steps",
"num_frames",
"height",
"width",
"guidance_scale",
"guidance_rescale",
"fps",
"seed",
"num_gpus",
"dit_cpu_offload",
"dit_layerwise_offload",
"text_encoder_cpu_offload",
"vae_cpu_offload",
"image_encoder_cpu_offload",
"use_fsdp_inference",
"enable_torch_compile",
"vsa_sparsity",
"tp_size",
"sp_size",
"data_path",
"max_train_steps",
"train_batch_size",
"learning_rate",
"num_latent_t",
"validation_dataset_file",
"lora_rank",
"dmd_use_vsa",
"dmd_vsa_sparsity",
"dmd_denoising_steps",
"real_score_guidance_scale",
"generator_update_interval",
"real_score_model_path",
"fake_score_model_path",
)
def duplicate_job(self, job_id: str, new_job_id: str) -> Job:
"""Create a new pending job with an existing job's configuration.
Runtime state (status, timings, logs, outputs) is not carried over.
"""
with self._jobs_lock:
source = self._jobs.get(job_id)
if source is None:
raise ValueError(f"Job {job_id} not found")
config = {f: copy.deepcopy(getattr(source, f)) for f in self.CONFIG_FIELDS}
return self.create_job(job_id=new_job_id, **config)
#: Editable exactly when startable: the same set start_job() accepts.
EDITABLE_STATUSES = (JobStatus.PENDING, JobStatus.FAILED, JobStatus.STOPPED)
def update_job_config(self, job_id: str, updates: dict[str, Any]) -> Job:
"""Edit the configuration of a job that has not produced a result."""
with self._jobs_lock:
job = self._jobs.get(job_id)
if job is None:
raise ValueError(f"Job {job_id} not found")
if job.status not in self.EDITABLE_STATUSES:
allowed = ", ".join(s.value for s in self.EDITABLE_STATUSES)
raise ValueError(f"Job is {job.status.value}; only {allowed} jobs can be edited. "
"Duplicate it instead.")
unknown = set(updates) - set(self.CONFIG_FIELDS)
if unknown:
raise ValueError(f"Not editable: {', '.join(sorted(unknown))}")
for field_name, value in updates.items():
setattr(job, field_name, value)
self._save_job(job)
return job
def start_job(self, job_id: str) -> Job:
"""Start (or restart) a pending / stopped / failed job.
@@ -623,6 +792,8 @@ class JobRunner:
workload_type: str,
num_gpus: int,
dit_cpu_offload: bool = False,
dit_layerwise_offload: bool = False,
override_pipeline_cls_name: str | None = None,
text_encoder_cpu_offload: bool = False,
vae_cpu_offload: bool = False,
image_encoder_cpu_offload: bool = False,
@@ -638,6 +809,10 @@ class JobRunner:
workload_type,
num_gpus,
dit_cpu_offload,
dit_layerwise_offload,
# Ref2VA loads different DiT weights (transformer_ref), so the
# override must key the cache or a t2v/i2v generator gets reused.
override_pipeline_cls_name,
text_encoder_cpu_offload,
vae_cpu_offload,
image_encoder_cpu_offload,
@@ -650,8 +825,21 @@ class JobRunner:
# Generators are cached by model_id and configuration parameters
with self._generators_lock:
if cache_key in self._generators:
return self._generators[cache_key]
cached = self._generators.get(cache_key)
if cached is not None:
if _generator_is_alive(cached):
return cached
# Workers can exit while a generator sits idle in the cache;
# reusing it fails every later job with the same config.
logger.warning(
"Cached generator for %s has dead workers; reloading.",
model_id,
)
self._generators.pop(cache_key, None)
try:
cached.shutdown()
except Exception:
logger.debug("Shutdown of the dead generator failed", exc_info=True)
# Import lazily so starting the server is fast even without a GPU.
from fastvideo import VideoGenerator
@@ -677,6 +865,11 @@ class JobRunner:
gen = VideoGenerator.from_pretrained(
model_id,
workload_type=workload_type,
num_gpus=num_gpus,
dit_layerwise_offload=dit_layerwise_offload,
**({
"override_pipeline_cls_name": override_pipeline_cls_name
} if override_pipeline_cls_name else {}),
dit_cpu_offload=dit_cpu_offload,
text_encoder_cpu_offload=text_encoder_cpu_offload,
vae_cpu_offload=vae_cpu_offload,
@@ -706,10 +899,9 @@ class JobRunner:
def _run_training_job(self, job: Job):
"""Run a finetuning, distillation, or LoRA job via subprocess."""
buf = job._log_buf
os.makedirs(self.log_dir, exist_ok=True)
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
job_output_dir = os.path.join(self.output_dir, job.id)
os.makedirs(job_output_dir, exist_ok=True)
job.log_file_path = _job_log_path(self.output_dir, job.id)
if not job.data_path or not os.path.isdir(job.data_path):
job.status = JobStatus.FAILED
@@ -827,8 +1019,8 @@ class JobRunner:
def _run_inference_job(self, job: Job):
buf = job._log_buf
os.makedirs(self.log_dir, exist_ok=True)
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
os.makedirs(os.path.join(self.output_dir, job.id), exist_ok=True)
job.log_file_path = _job_log_path(self.output_dir, job.id)
# Add file handler to persist logs
file_handler = logging.FileHandler(job.log_file_path, mode='w', encoding='utf-8')
@@ -875,62 +1067,45 @@ class JobRunner:
buf.phase = "loading model"
logger.info("Loading model...")
# Run generator creation in a background thread so we
# can poll _stop_event while the (potentially slow)
# model download / load is in progress.
_gen_result: list[Any] = []
_gen_error: list[BaseException] = []
# The generator MUST be created on this thread: building it spawns
# the executor's worker processes, and they are torn down if the
# creating thread exits. Running it in a helper thread (to poll
# _stop_event during load) made every collective_rpc fail with
# ConnectionResetError.
if job._stop_event.is_set():
job.status = JobStatus.STOPPED
job.finished_at = time.time()
self._save_job(job)
logger.warning("Job %s stopped before model loading", job.id)
buf.phase = "stopped"
return
def _load_generator() -> None:
try:
gen = self._get_or_create_generator(
job.model_id,
job.workload_type,
job.num_gpus,
dit_cpu_offload=job.dit_cpu_offload,
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
vae_cpu_offload=job.vae_cpu_offload,
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
use_fsdp_inference=job.use_fsdp_inference,
enable_torch_compile=(job.enable_torch_compile),
vsa_sparsity=job.vsa_sparsity,
tp_size=job.tp_size,
sp_size=job.sp_size,
log_queue=log_queue,
)
_gen_result.append(gen)
except BaseException as exc:
_gen_error.append(exc)
loader = threading.Thread(
target=_load_generator,
daemon=True,
generator = self._get_or_create_generator(
job.model_id,
job.workload_type,
job.num_gpus,
dit_cpu_offload=job.dit_cpu_offload,
dit_layerwise_offload=job.dit_layerwise_offload,
override_pipeline_cls_name=(MINIMAX_H3_REF2VA_PIPELINE if job.references else None),
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
vae_cpu_offload=job.vae_cpu_offload,
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
use_fsdp_inference=job.use_fsdp_inference,
enable_torch_compile=(job.enable_torch_compile),
vsa_sparsity=job.vsa_sparsity,
tp_size=job.tp_size,
sp_size=job.sp_size,
log_queue=log_queue,
)
loader.start()
while loader.is_alive():
if job._stop_event.is_set():
job.status = JobStatus.STOPPED
job.finished_at = time.time()
self._save_job(job)
logger.warning(
"Job %s stopped during model loading",
job.id,
)
buf.phase = "stopped"
return
loader.join(timeout=0.5)
if _gen_error:
raise _gen_error[0]
generator = _gen_result[0]
buf.phase = "generating"
logger.info("Starting generation for job %s (model=%s)", job.id, job.model_id)
# Without a name FastVideo derives the filename from the prompt.
safe_name = re.sub(r'[\\/:*?"<>|]+', "", job.name).strip().strip(".")
output_target = (os.path.join(job_output_dir, f"{safe_name[:80]}.mp4") if safe_name else job_output_dir)
gen_kwargs: dict[str, Any] = {
"prompt": job.prompt,
"output_path": job_output_dir,
"output_path": output_target,
"save_video": True,
"num_inference_steps": job.num_inference_steps,
"num_frames": job.num_frames,
@@ -945,6 +1120,12 @@ class JobRunner:
}
if job.image_path:
gen_kwargs["image_path"] = job.image_path
if job.references:
gen_kwargs["references"] = _build_h3_references(job.references)
if job.last_image_path:
# _prepare_fl2va requires a PIL image, not a path.
from PIL import Image as _PILImage
gen_kwargs["last_image"] = _PILImage.open(job.last_image_path)
generator.generate_video(**gen_kwargs)
buf.phase = "saving"
@@ -977,7 +1158,7 @@ class JobRunner:
except Exception as exception:
error_msg = str(exception)
logger.error("Critical error in job thread: %s", error_msg)
logger.exception("Critical error in job thread: %s", error_msg)
job.status = JobStatus.FAILED
job.error = f"Critical error ({type(exception).__name__}): {error_msg}"
job.finished_at = time.time()
@@ -1,15 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
"""Request model for creating a job."""
from typing import Any
from pydantic import BaseModel
class CreateJobRequest(BaseModel):
model_id: str
name: str = ""
prompt: str
workload_type: str = "t2v"
job_type: str = "inference"
image_path: str = ""
last_image_path: str = ""
references: list[dict[str, Any]] | None = None
data_path: str = ""
max_train_steps: int = 1000
train_batch_size: int = 1
@@ -28,6 +33,7 @@ class CreateJobRequest(BaseModel):
seed: int = 1024
num_gpus: int = 1
dit_cpu_offload: bool = False
dit_layerwise_offload: bool = False
text_encoder_cpu_offload: bool = False
vae_cpu_offload: bool = False
image_encoder_cpu_offload: bool = False
+92 -2
View File
@@ -18,6 +18,7 @@ import argparse
import contextlib
import logging
import os
import re
import shutil
import signal
import time
@@ -116,7 +117,30 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
return _available_models
def _safe_upload_name(filename: str | None, ext: str) -> str:
"""A filesystem-safe version of the client's filename, keeping it readable.
Uploads live under a per-file uuid directory, so the basename does not have
to be unique -- only safe. Keeping the original name means the path stays
self-describing wherever it travels: the database, job logs, and payloads
copied back out to the API.
"""
stem = os.path.basename(filename or "").rsplit(".", 1)[0]
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")
return f"{stem[:80] or 'upload'}{ext}"
def _upload_destination(ext: str, filename: str | None) -> str:
"""<upload_dir>/<uuid4>/<safe original name><ext>"""
directory = os.path.join(upload_dir, uuid.uuid4().hex)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, _safe_upload_name(filename, ext))
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
ALLOWED_MEDIA_EXTENSIONS = (ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS)
@app.post("/api/upload-image")
@@ -136,8 +160,7 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
f"{', '.join(ALLOWED_IMAGE_EXTENSIONS)}"),
)
os.makedirs(upload_dir, exist_ok=True)
unique_name = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(upload_dir, unique_name)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
@@ -150,6 +173,47 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
return {"path": os.path.abspath(dest_path)}
@app.post("/api/upload-media")
async def upload_media(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
"""Upload an image, video or audio file for Ref2VA references.
Returns the absolute path plus the media_type MiniMax-H3 expects, so the
caller does not have to re-derive it from the extension.
"""
global upload_dir # noqa: PLW0603
if not upload_dir:
raise HTTPException(
status_code=503,
detail="Upload directory not configured",
)
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_MEDIA_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=(f"Invalid file type. Allowed: "
f"{', '.join(sorted(ALLOWED_MEDIA_EXTENSIONS))}"),
)
if ext in ALLOWED_VIDEO_EXTENSIONS:
media_type = "video"
elif ext in ALLOWED_AUDIO_EXTENSIONS:
media_type = "audio"
else:
media_type = "image"
os.makedirs(upload_dir, exist_ok=True)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save upload: {e}",
) from e
return {"path": os.path.abspath(dest_path), "media_type": media_type}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -282,10 +346,13 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
job = job_runner.create_job(
job_id=str(uuid.uuid4()),
model_id=req.model_id,
name=req.name or "",
prompt=req.prompt,
workload_type=req.workload_type or "t2v",
job_type=job_type,
image_path=req.image_path or "",
last_image_path=req.last_image_path or "",
references=req.references or [],
data_path=data_path,
max_train_steps=req.max_train_steps,
train_batch_size=req.train_batch_size,
@@ -304,6 +371,7 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
seed=req.seed,
num_gpus=req.num_gpus,
dit_cpu_offload=req.dit_cpu_offload,
dit_layerwise_offload=req.dit_layerwise_offload,
text_encoder_cpu_offload=req.text_encoder_cpu_offload,
vae_cpu_offload=req.vae_cpu_offload,
image_encoder_cpu_offload=req.image_encoder_cpu_offload,
@@ -336,6 +404,28 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
return job.to_dict()
@app.post("/api/jobs/{job_id}/duplicate", status_code=201)
def duplicate_job(job_id: str) -> dict[str, Any]:
"""Create a new pending job with the same configuration as an existing one."""
try:
job = job_runner.duplicate_job(job_id, str(uuid.uuid4()))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return job.to_dict()
@app.patch("/api/jobs/{job_id}")
def update_job(job_id: str, updates: dict[str, Any]) -> dict[str, Any]:
"""Edit a pending job's configuration. Started jobs cannot be edited."""
try:
job = job_runner.update_job_config(job_id, updates)
except ValueError as e:
detail = str(e)
status = 404 if "not found" in detail else 400
raise HTTPException(status_code=status, detail=detail) from e
return job.to_dict()
@app.post("/api/jobs/{job_id}/start")
def start_job(job_id: str) -> dict[str, Any]:
"""Start (or restart) a pending / stopped / failed job."""
@@ -22,15 +22,35 @@ import { useStore } from '@/hooks/useStore';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import {
createJob,
updateJob,
getDatasets,
getModels,
uploadImage,
uploadMedia,
type CreateJobRequest,
type Model,
} from '@/lib/api';
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
import type { JobType } from '@/lib/types';
import {
H3_MAX_REFERENCES,
labelReferences,
referencePromptSeed,
validateReferences,
type H3Reference,
} from '@/lib/h3References';
import {
EMPTY_H3_PROMPT_FIELDS,
H3_PROMPT_SECTIONS,
H3_SECTION_HINTS,
H3_SECTION_LABELS,
isEmptyPromptFields,
parseH3Prompt,
serializeH3Prompt,
type H3PromptFields,
} from '@/lib/h3Prompt';
import { jobToFormFields, type JobLike } from '@/lib/jobToFields';
export interface CreateJobModalProps {
isOpen: boolean;
@@ -38,6 +58,10 @@ export interface CreateJobModalProps {
onSuccess: () => void;
jobType: JobType;
workloadType: string;
/** When set, the modal edits this pending job instead of creating a new one. */
editingJob?: JobLike | null;
/** Show the configuration without allowing changes (started/finished jobs). */
readOnly?: boolean;
}
export default function CreateJobModal({
@@ -46,6 +70,8 @@ export default function CreateJobModal({
onSuccess,
jobType,
workloadType,
editingJob,
readOnly = false,
}: CreateJobModalProps) {
const { options } = useStore(defaultOptionsStore);
@@ -55,8 +81,22 @@ export default function CreateJobModal({
const [models, setModels] = React.useState<Model[]>([]);
const [modelId, setModelId] = React.useState('');
const [name, setName] = React.useState('');
const [prompt, setPrompt] = React.useState('');
const [imagePath, setImagePath] = React.useState('');
const [lastImagePath, setLastImagePath] = React.useState('');
const [references, setReferences] = React.useState<H3Reference[]>([]);
const [isUploadingReference, setIsUploadingReference] = React.useState(false);
const [referenceError, setReferenceError] = React.useState<string | null>(null);
const [promptFields, setPromptFields] = React.useState<H3PromptFields>(
EMPTY_H3_PROMPT_FIELDS,
);
const [useGuidedPrompt, setUseGuidedPrompt] = React.useState(true);
const [lastImageFileName, setLastImageFileName] = React.useState('');
const [isUploadingLastImage, setIsUploadingLastImage] = React.useState(false);
const [lastImageUploadError, setLastImageUploadError] = React.useState<
string | null
>(null);
const [imageFileName, setImageFileName] = React.useState('');
const [isUploadingImage, setIsUploadingImage] = React.useState(false);
const [negativePrompt, setNegativePrompt] = React.useState('');
@@ -70,12 +110,50 @@ export default function CreateJobModal({
const [seed, setSeed] = React.useState(1024);
const [numGpus, setNumGpus] = React.useState(1);
const [ditCpuOffload, setDitCpuOffload] = React.useState(false);
const [ditLayerwiseOffload, setDitLayerwiseOffload] = React.useState(false);
const [textEncoderCpuOffload, setTextEncoderCpuOffload] =
React.useState(false);
const [vaeCpuOffload, setVaeCpuOffload] = React.useState(false);
const [imageEncoderCpuOffload, setImageEncoderCpuOffload] =
React.useState(false);
const [useFsdpInference, setUseFsdpInference] = React.useState(false);
// H3 is the only registered model with an end frame or references.
const supportsLastImage = modelId.toLowerCase().includes('minimax-h3');
const usingReferences = supportsLastImage && references.length > 0;
// JobCard re-renders on every job-list poll, so `editingJob` is a fresh
// object each time. Effects must depend on these, never on the object.
const editingJobId = editingJob?.id ?? null;
const editingJobModelId = editingJob?.model_id ?? null;
// Layerwise offload and FSDP compete for the DiT weights and FastVideoArgs
// silently picks a winner (fastvideo_args.py:859); resolve it visibly here.
// dit_cpu_offload is deliberately not interlocked -- it is a modifier, not a
// competing strategy.
const handleDitLayerwiseOffloadChange = React.useCallback((next: boolean) => {
setDitLayerwiseOffload(next);
if (next) {
setUseFsdpInference(false);
}
}, []);
const handleUseFsdpInferenceChange = React.useCallback((next: boolean) => {
setUseFsdpInference(next);
if (next) {
setDitLayerwiseOffload(false);
}
}, []);
const handleNumGpusChange = React.useCallback((next: number) => {
setNumGpus(next);
if (next > 1) {
// Dropping back to one GPU leaves FSDP alone: single-GPU FSDP is a
// valid way to reach its CPU offload (docs/inference/offloading.md).
setUseFsdpInference(true);
setDitLayerwiseOffload(false);
}
}, []);
const [enableTorchCompile, setEnableTorchCompile] = React.useState(false);
const [vsaSparsity, setVsaSparsity] = React.useState(0);
const [tpSize, setTpSize] = React.useState(-1);
@@ -126,6 +204,47 @@ export default function CreateJobModal({
const justOpened = isOpen && !justOpenedRef.current;
justOpenedRef.current = isOpen;
if (!justOpened) return;
if (editingJob) {
// Must not fall through to the defaults below: a partially-seeded
// form silently edits values the user never saw.
const f = jobToFormFields(editingJob);
setModelId(f.modelId);
setName(f.name);
setPrompt(f.prompt);
setNegativePrompt(f.negativePrompt);
setImagePath(f.imagePath);
setImageFileName(f.imagePath.split('/').pop() ?? '');
setLastImagePath(f.lastImagePath);
setLastImageFileName(f.lastImagePath.split('/').pop() ?? '');
setReferences(f.references);
setPromptFields(f.promptFields ?? EMPTY_H3_PROMPT_FIELDS);
setUseGuidedPrompt(f.promptFields !== null);
setNumInferenceSteps(f.numInferenceSteps);
setNumFrames(f.numFrames);
setHeight(f.height);
setWidth(f.width);
setGuidanceScale(f.guidanceScale);
setGuidanceRescale(f.guidanceRescale);
setFps(f.fps);
setSeed(f.seed);
setNumGpus(f.numGpus);
setDitCpuOffload(f.ditCpuOffload);
setDitLayerwiseOffload(f.ditLayerwiseOffload);
setTextEncoderCpuOffload(f.textEncoderCpuOffload);
setVaeCpuOffload(f.vaeCpuOffload);
setImageEncoderCpuOffload(f.imageEncoderCpuOffload);
setUseFsdpInference(f.useFsdpInference);
setEnableTorchCompile(f.enableTorchCompile);
setVsaSparsity(f.vsaSparsity);
setTpSize(f.tpSize);
setSpSize(f.spSize);
setReferenceError(null);
setModelLoadError(null);
setImageUploadError(null);
setLastImageUploadError(null);
setSubmitError(null);
return;
}
const opts = options;
setNumInferenceSteps(opts.numInferenceSteps);
setNumFrames(workloadType === 't2i' ? 1 : opts.numFrames);
@@ -137,6 +256,7 @@ export default function CreateJobModal({
setSeed(opts.seed);
setNumGpus(opts.numGpus);
setDitCpuOffload(opts.ditCpuOffload);
setDitLayerwiseOffload(opts.ditLayerwiseOffload ?? false);
setTextEncoderCpuOffload(opts.textEncoderCpuOffload);
setVaeCpuOffload(opts.vaeCpuOffload);
setImageEncoderCpuOffload(opts.imageEncoderCpuOffload);
@@ -151,8 +271,16 @@ export default function CreateJobModal({
inferenceWorkload as 't2v' | 'i2v' | 't2i',
),
);
setName('');
setImagePath('');
setImageFileName('');
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
setReferences([]);
setReferenceError(null);
setPromptFields(EMPTY_H3_PROMPT_FIELDS);
setUseGuidedPrompt(true);
setSelectedDatasetId('');
setSelectedValidationDatasetId('');
setModelLoadError(null);
@@ -168,7 +296,7 @@ export default function CreateJobModal({
setRealScoreModelPath('');
setFakeScoreModelPath('');
}
}, [isOpen, workloadType, inferenceWorkload, options]);
}, [isOpen, workloadType, inferenceWorkload, options, editingJobId]);
// Load the models available for this workload.
React.useEffect(() => {
@@ -188,7 +316,16 @@ export default function CreateJobModal({
opts,
inferenceWorkload as 't2v' | 'i2v' | 't2i',
);
const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? '');
// When editing, the job's own model wins over the workload default --
// this resolves after the seeding effect, so choosing a default here
// would silently swap the model out from under the user.
const editedId = editingJobModelId;
const chosen =
editedId && ids.includes(editedId)
? editedId
: ids.includes(defaultId)
? defaultId
: (list[0]?.id ?? '');
setModelId(chosen);
if (workloadType === 'dmd_t2v') {
setRealScoreModelPath(chosen);
@@ -210,7 +347,7 @@ export default function CreateJobModal({
return () => {
stale = true;
};
}, [isOpen, inferenceWorkload, workloadType]);
}, [isOpen, inferenceWorkload, workloadType, editingJobModelId]);
// Training jobs need a dataset; load the ready datasets when relevant.
React.useEffect(() => {
@@ -262,6 +399,106 @@ export default function CreateJobModal({
}
}
async function handleLastImageChange(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
if (!file) {
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
return;
}
setIsUploadingLastImage(true);
setLastImageFileName(file.name);
setLastImageUploadError(null);
try {
const { path } = await uploadImage(file);
setLastImagePath(path);
} catch (error) {
console.error('Failed to upload end image:', error);
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(
error instanceof Error
? `${error.message}. Choose the image again to retry.`
: 'The image could not be uploaded. Choose it again to retry.',
);
} finally {
setIsUploadingLastImage(false);
}
}
async function handleAddReference(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-picking the same file
if (!file) return;
setIsUploadingReference(true);
setReferenceError(null);
try {
const { path, media_type } = await uploadMedia(file);
const next: H3Reference[] = [
...references,
{
id: `${Date.now()}-${file.name}`,
source: path,
media_type,
fileName: file.name,
},
];
setReferences(next);
setReferenceError(validateReferences(next));
} catch (error) {
console.error('Failed to upload reference:', error);
setReferenceError(
error instanceof Error ? error.message : 'The file could not be uploaded.',
);
} finally {
setIsUploadingReference(false);
}
}
function removeReference(id: string) {
const next = references.filter((r) => r.id !== id);
setReferences(next);
setReferenceError(validateReferences(next));
}
function seedPromptFields() {
setPromptFields({
...EMPTY_H3_PROMPT_FIELDS,
...referencePromptSeed(references),
});
setUseGuidedPrompt(true);
}
function setPromptField(section: string, value: string) {
setPromptFields((prev) => ({ ...prev, [section]: value }));
}
// Switching between the guided fields and the raw editor keeps whatever was
// typed: serialize on the way out, parse back on the way in.
function toggleGuidedPrompt() {
if (useGuidedPrompt) {
if (!isEmptyPromptFields(promptFields)) {
setPrompt(serializeH3Prompt(promptFields));
}
setUseGuidedPrompt(false);
} else {
const parsed = parseH3Prompt(prompt);
if (parsed) setPromptFields(parsed);
setUseGuidedPrompt(true);
}
}
function clearLastImage() {
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
}
function clearImage() {
setImagePath('');
setImageFileName('');
@@ -271,7 +508,16 @@ export default function CreateJobModal({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (isInference && workloadType === 'i2v' && !imagePath) return;
if (isInference && workloadType === 'i2v' && !imagePath && !usingReferences)
return;
if (usingReferences && validateReferences(references)) return;
if (
usingReferences &&
useGuidedPrompt &&
isEmptyPromptFields(promptFields) &&
!prompt.trim()
)
return;
// Send the dataset id; the backend resolves it to the on-disk media dir.
const effectiveDataPath = selectedDatasetId ?? '';
if (!isInference && !selectedDatasetId) return;
@@ -285,14 +531,35 @@ export default function CreateJobModal({
try {
const payload: CreateJobRequest = {
model_id: modelId,
prompt,
name: name.trim(),
prompt:
usingReferences && useGuidedPrompt && !isEmptyPromptFields(promptFields)
? serializeH3Prompt(promptFields)
: prompt,
workload_type: workloadType,
job_type: effectiveJobType,
...(isInference
? {
...(workloadType === 'i2v' && imagePath
// Ref2VA and the FL2VA keyframes are mutually exclusive:
// _prepare_ref2va rejects image_path/last_image_path outright
// when references are present.
...(workloadType === 'i2v' && !usingReferences && imagePath
? { image_path: imagePath }
: {}),
...(workloadType === 'i2v' &&
supportsLastImage &&
!usingReferences &&
lastImagePath
? { last_image_path: lastImagePath }
: {}),
...(workloadType === 'i2v' && supportsLastImage && references.length
? {
references: references.map((r) => ({
source: r.source,
media_type: r.media_type,
})),
}
: {}),
negative_prompt: negativePrompt,
num_inference_steps: numInferenceSteps,
num_frames: numFrames,
@@ -304,6 +571,7 @@ export default function CreateJobModal({
seed,
num_gpus: numGpus,
dit_cpu_offload: ditCpuOffload,
dit_layerwise_offload: ditLayerwiseOffload,
text_encoder_cpu_offload: textEncoderCpuOffload,
vae_cpu_offload: vaeCpuOffload,
image_encoder_cpu_offload: imageEncoderCpuOffload,
@@ -334,7 +602,14 @@ export default function CreateJobModal({
: {}),
}),
};
await createJob(payload);
if (editingJob) {
await updateJob(
editingJob.id,
payload as unknown as Record<string, unknown>,
);
} else {
await createJob(payload);
}
onSuccess();
onClose();
} catch (err) {
@@ -356,9 +631,9 @@ export default function CreateJobModal({
const workloadLabel =
WORKLOAD_OPTIONS[jobType]?.find((o) => o.type === workloadType)?.label ?? '';
const title = `New ${jobType.charAt(0).toUpperCase() + jobType.slice(1)} Job${
workloadLabel ? ` (${workloadLabel})` : ''
}`;
const title = `${readOnly ? 'View' : editingJob ? 'Edit' : 'New'} ${
jobType.charAt(0).toUpperCase() + jobType.slice(1)
} Job${workloadLabel ? ` (${workloadLabel})` : ''}`;
return (
<Dialog
@@ -385,6 +660,23 @@ export default function CreateJobModal({
autoComplete="off"
className="flex flex-col gap-3.5"
>
{/* disabled cascades to every control inside; display:contents
keeps the parent's flex layout. */}
<fieldset
disabled={readOnly}
style={{ display: 'contents' }}
className="contents"
>
<FieldRow htmlFor="modal-name" label="Name (optional)">
<Input
id="modal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Shown on the job card and used for the output filename"
disabled={isSubmitting}
/>
</FieldRow>
<FieldRow htmlFor="modal-modelId" label="Model">
<NativeSelect
id="modal-modelId"
@@ -429,12 +721,12 @@ export default function CreateJobModal({
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp"
onChange={handleImageChange}
disabled={isSubmitting || isUploadingImage}
disabled={isSubmitting || isUploadingImage || usingReferences}
aria-describedby={
imageUploadError ? 'modal-image-error' : undefined
}
aria-invalid={imageUploadError ? true : undefined}
required
required={!usingReferences}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{imageFileName && (
@@ -462,24 +754,169 @@ export default function CreateJobModal({
</FieldRow>
)}
<FieldRow
htmlFor="modal-prompt"
label={isInference ? 'Prompt' : 'Description'}
>
<Textarea
id="modal-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={isInference ? 3 : 2}
placeholder={
isInference
? 'A curious raccoon peers through a vibrant field of yellow sunflowers…'
: 'Brief description of this training job…'
}
required
disabled={isSubmitting}
/>
</FieldRow>
{isInference && workloadType === 'i2v' && supportsLastImage && (
<FieldRow htmlFor="modal-last-image" label="End Frame (optional)">
<Input
id="modal-last-image"
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp"
onChange={handleLastImageChange}
disabled={isSubmitting || isUploadingLastImage}
aria-describedby={
lastImageUploadError ? 'modal-last-image-error' : undefined
}
aria-invalid={lastImageUploadError ? true : undefined}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{lastImageFileName && (
<span className="mt-0.5 text-xs text-muted-foreground">
{isUploadingLastImage ? 'Uploading…' : lastImageFileName} ·{' '}
<button
type="button"
onClick={clearLastImage}
disabled={isSubmitting || isUploadingLastImage}
className="text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Clear
</button>
</span>
)}
{lastImageUploadError && (
<p
id="modal-last-image-error"
role="alert"
className="text-sm text-destructive"
>
{lastImageUploadError}
</p>
)}
</FieldRow>
)}
{isInference && workloadType === 'i2v' && supportsLastImage && (
<FieldRow htmlFor="modal-reference" label="References (Ref2VA)">
<Input
id="modal-reference"
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp,.mp4,.mov,.mkv,.webm,.avi,.wav,.mp3,.flac,.m4a,.ogg"
onChange={handleAddReference}
disabled={
isSubmitting ||
isUploadingReference ||
references.length >= H3_MAX_REFERENCES
}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{isUploadingReference && (
<span className="mt-0.5 text-xs text-muted-foreground">
Uploading…
</span>
)}
{references.length > 0 && (
<ul className="mt-1 flex list-none flex-col gap-1 p-0">
{references.map((reference, index) => (
<li
key={reference.id}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<code className="font-mono text-accent-blue">
{labelReferences(references)[index]}
</code>
<span className="truncate">{reference.fileName}</span>
<button
type="button"
onClick={() => removeReference(reference.id)}
disabled={isSubmitting}
className="ml-auto text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
{references.length > 0 && (
<button
type="button"
onClick={seedPromptFields}
disabled={isSubmitting}
className="mt-1 self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Fill prompt sections from references
</button>
)}
{referenceError && (
<p role="alert" className="mt-0.5 text-xs text-destructive">
{referenceError}
</p>
)}
<span className="mt-0.5 text-xs text-muted-foreground">
Ref2VA replaces the keyframes: up to 9 images, 3 videos, 3 audio
(12 total). Audio needs at least one image or video.
</span>
</FieldRow>
)}
{usingReferences && useGuidedPrompt ? (
/* Six-section format from the model's reference prompt guide. */
<>
{H3_PROMPT_SECTIONS.map((section) => (
<FieldRow
key={section}
htmlFor={`modal-prompt-${section}`}
label={H3_SECTION_LABELS[section]}
>
<Textarea
id={`modal-prompt-${section}`}
value={promptFields[section]}
onChange={(e) => setPromptField(section, e.target.value)}
rows={section === 'detailed_description' ? 5 : 2}
placeholder={H3_SECTION_HINTS[section]}
disabled={isSubmitting}
/>
</FieldRow>
))}
<button
type="button"
onClick={toggleGuidedPrompt}
disabled={isSubmitting}
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Edit as raw prompt
</button>
</>
) : (
<>
<FieldRow
htmlFor="modal-prompt"
label={isInference ? 'Prompt' : 'Description'}
>
<Textarea
id="modal-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={isInference ? 3 : 2}
placeholder={
isInference
? 'A curious raccoon peers through a vibrant field of yellow sunflowers…'
: 'Brief description of this training job…'
}
required={!(usingReferences && useGuidedPrompt)}
disabled={isSubmitting}
/>
</FieldRow>
{usingReferences && (
<button
type="button"
onClick={toggleGuidedPrompt}
disabled={isSubmitting}
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Edit as prompt sections
</button>
)}
</>
)}
{isInference && (
<FieldRow htmlFor="modal-negative-prompt" label="Negative Prompt">
@@ -849,6 +1286,13 @@ export default function CreateJobModal({
onChange={setDitCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-dit-layerwise-offload"
label="DiT Layerwise Offload"
checked={ditLayerwiseOffload}
onChange={handleDitLayerwiseOffloadChange}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-text-encoder-cpu-offload"
label="Text Encoder CPU Offload"
@@ -860,7 +1304,7 @@ export default function CreateJobModal({
id="modal-use-fsdp-inference"
label="Use FSDP Inference"
checked={useFsdpInference}
onChange={setUseFsdpInference}
onChange={handleUseFsdpInferenceChange}
disabled={isSubmitting}
/>
<ToggleRow
@@ -891,7 +1335,7 @@ export default function CreateJobModal({
max={8}
step={1}
value={numGpus}
onChange={setNumGpus}
onChange={handleNumGpusChange}
disabled={isSubmitting}
/>
<NumberRow
@@ -906,6 +1350,8 @@ export default function CreateJobModal({
</details>
)}
</fieldset>
<div className="flex flex-col items-start gap-2">
{submitError && (
<p role="alert" className="text-sm text-destructive">
@@ -914,14 +1360,22 @@ export default function CreateJobModal({
)}
<Button
type="submit"
hidden={readOnly}
disabled={
readOnly ||
isSubmitting ||
isUploadingImage ||
!!modelLoadError ||
!!datasetLoadError
}
>
{isSubmitting ? 'Creating…' : 'Create Job'}
{isSubmitting
? editingJob
? 'Saving…'
: 'Creating…'
: editingJob
? 'Save Changes'
: 'Create Job'}
</Button>
</div>
</form>
@@ -3,11 +3,13 @@
import * as React from 'react';
import { Timer } from 'lucide-react';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useStore } from '@/hooks/useStore';
import {
deleteJob,
duplicateJob,
downloadJobVideo,
startJob,
stopJob,
@@ -62,6 +64,8 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
const isSelected = activeJobId === job.id;
const [isLoading, setIsLoading] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(false);
const [isViewing, setIsViewing] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
const elapsedTime = computeElapsed(job, currentTime);
@@ -103,6 +107,21 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
}
}
async function handleDuplicate(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
setIsLoading(true);
try {
await duplicateJob(job.id);
onJobUpdated?.();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to duplicate job');
} finally {
setIsLoading(false);
}
}
async function handleDelete(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -156,16 +175,23 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold text-foreground">
{job.model_id}
{job.name?.trim() || job.model_id}
</span>
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
{job.status}
</Badge>
</span>
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{job.prompt}
{job.name?.trim() ? `${job.model_id} · ${job.prompt}` : job.prompt}
</span>
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Short job id; logs and output dirs are keyed on the full UUID. */}
<span
className="font-mono text-muted-foreground/80"
title={job.id}
>
{job.id.slice(0, 8)}
</span>
{job.job_type === 'inference' ? (
<>
<span>{job.num_frames} frames</span>
@@ -226,6 +252,51 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
Download Video
</Button>
)}
{!(
job.status === 'pending' ||
job.status === 'failed' ||
job.status === 'stopped'
) && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsViewing(true);
}}
disabled={isLoading}
title="View this job's configuration"
>
View
</Button>
)}
{(job.status === 'pending' ||
job.status === 'failed' ||
job.status === 'stopped') && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}}
disabled={isLoading}
title="Edit this job's configuration"
>
Edit
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleDuplicate}
disabled={isLoading}
title="Create a new pending job with this configuration"
>
Duplicate
</Button>
<Button
size="sm"
variant="destructive"
@@ -235,6 +306,30 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
Delete
</Button>
</div>
{isViewing && (
<CreateJobModal
isOpen
readOnly
editingJob={job}
jobType={(job.job_type ?? 'inference') as never}
workloadType={job.workload_type ?? 't2v'}
onClose={() => setIsViewing(false)}
onSuccess={() => setIsViewing(false)}
/>
)}
{isEditing && (
<CreateJobModal
isOpen
editingJob={job}
jobType={(job.job_type ?? 'inference') as never}
workloadType={job.workload_type ?? 't2v'}
onClose={() => setIsEditing(false)}
onSuccess={() => {
setIsEditing(false);
onJobUpdated?.();
}}
/>
)}
</article>
);
}
@@ -153,9 +153,16 @@ export default function JobDetailsSidebar({
}}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="m-0 text-base font-semibold text-foreground">
Job Details
</h2>
<div className="min-w-0">
<h2 className="m-0 text-base font-semibold text-foreground">
Job Details
</h2>
{/* Full job id: keys ~/h3_studio_logs/<id>.log, the output directory
and every API route, so make it selectable for copy/paste. */}
<code className="mt-0.5 block select-all truncate font-mono text-xs text-muted-foreground">
{job.id}
</code>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
+61
View File
@@ -56,6 +56,8 @@ export function getJobVideoUrl(jobId: string): string {
export interface CreateJobRequest {
model_id: string;
/** Optional label; the card and output filename fall back to the prompt. */
name?: string;
prompt: string;
workload_type?: string;
job_type?: JobType;
@@ -152,6 +154,65 @@ export async function updateSettings(
return response.json();
}
export type MediaType = "image" | "video" | "audio";
/**
* Upload an image, video or audio file for a MiniMax-H3 Ref2VA reference.
* The server derives media_type from the extension and returns it, so callers
* do not have to duplicate that mapping.
*/
/** Create a new pending job with the same configuration as an existing one. */
export async function duplicateJob(jobId: string): Promise<{ id: string }> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/duplicate`, {
method: "POST",
});
if (!response.ok) {
const err = await response
.json()
.catch(() => ({ detail: "Duplicate failed" }));
throw new Error(err.detail || "Duplicate failed");
}
return response.json();
}
/** Edit a pending job's configuration. Started jobs are rejected by the API. */
export async function updateJob(
jobId: string,
updates: Record<string, unknown>,
): Promise<unknown> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: "Update failed" }));
throw new Error(err.detail || "Update failed");
}
return response.json();
}
export async function uploadMedia(
file: File,
): Promise<{ path: string; media_type: MediaType }> {
const baseApiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${baseApiUrl}/upload-media`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const err = await response
.json()
.catch(() => ({ detail: "Upload failed" }));
throw new Error(err.detail || "Upload failed");
}
return response.json();
}
export async function uploadImage(file: File): Promise<{ path: string }> {
const baseApiUrl = getApiBaseUrl();
const formData = new FormData();
@@ -16,6 +16,7 @@ export interface DefaultOptions {
seed: number;
numGpus: number;
ditCpuOffload: boolean;
ditLayerwiseOffload: boolean;
textEncoderCpuOffload: boolean;
vaeCpuOffload: boolean;
imageEncoderCpuOffload: boolean;
@@ -44,6 +45,7 @@ export const DEFAULT_OPTIONS: DefaultOptions = {
seed: 1024,
numGpus: 1,
ditCpuOffload: false,
ditLayerwiseOffload: false,
textEncoderCpuOffload: false,
vaeCpuOffload: false,
imageEncoderCpuOffload: false,
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_H3_PROMPT_FIELDS,
H3_PROMPT_SECTIONS,
isEmptyPromptFields,
parseH3Prompt,
serializeH3Prompt,
type H3PromptFields,
} from "@/lib/h3Prompt";
const filled: H3PromptFields = {
subject_definitions: "<Subject 1> is the dog in <Picture 1>.",
summary: "[reference generation] The target video shows <Subject 1>.",
retention_analysis: "<Subject 1> (appears in [Shot 1]): fully_preserved - fur retained.",
detailed_description: "[Shot 1] A medium shot establishes <Subject 1>.",
overall_soundscape: "Room tone throughout.",
non_diegetic_music: "N/A",
};
describe("serializeH3Prompt", () => {
it("emits sections in order, flush left, blank-line separated", () => {
const out = serializeH3Prompt(filled);
expect(out).toBe(
[
"subject_definitions:\n<Subject 1> is the dog in <Picture 1>.",
"summary:\n[reference generation] The target video shows <Subject 1>.",
"retention_analysis:\n<Subject 1> (appears in [Shot 1]): fully_preserved - fur retained.",
"detailed_description:\n[Shot 1] A medium shot establishes <Subject 1>.",
"overall_soundscape:\nRoom tone throughout.",
"non_diegetic_music:\nN/A",
].join("\n\n"),
);
// content is never indented
expect(out).not.toMatch(/\n {2}\S/);
});
it("fills blank sections with N/A rather than dropping them", () => {
const out = serializeH3Prompt({ ...EMPTY_H3_PROMPT_FIELDS, summary: "x" });
for (const s of H3_PROMPT_SECTIONS) expect(out).toContain(`${s}:`);
expect(out).toContain("non_diegetic_music:\nN/A");
});
});
describe("parseH3Prompt", () => {
it("round-trips a serialized prompt", () => {
expect(parseH3Prompt(serializeH3Prompt(filled))).toEqual(filled);
});
it("keeps multi-line section bodies", () => {
const p = parseH3Prompt("summary:\nline one\nline two\n\ndetailed_description:\nd");
expect(p?.summary).toBe("line one\nline two");
expect(p?.detailed_description).toBe("d");
});
it("returns null for a plain prompt", () => {
expect(parseH3Prompt("a toy car drives into a plush dog")).toBeNull();
});
});
describe("isEmptyPromptFields", () => {
it("detects blank vs filled", () => {
expect(isEmptyPromptFields(EMPTY_H3_PROMPT_FIELDS)).toBe(true);
expect(isEmptyPromptFields(filled)).toBe(false);
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* MiniMax-H3 full-reference prompt sections. Serialization follows the worked
* example in the model's VIDEO_PROMPT_WRITING_GUIDE_ref_en.md: `section_name:`
* on its own line, content flush left, one blank line between sections.
*/
export const H3_PROMPT_SECTIONS = [
'subject_definitions',
'summary',
'retention_analysis',
'detailed_description',
'overall_soundscape',
'non_diegetic_music',
] as const;
export type H3PromptSection = (typeof H3_PROMPT_SECTIONS)[number];
export type H3PromptFields = Record<H3PromptSection, string>;
export const EMPTY_H3_PROMPT_FIELDS: H3PromptFields = {
subject_definitions: '',
summary: '',
retention_analysis: '',
detailed_description: '',
overall_soundscape: '',
non_diegetic_music: '',
};
export const H3_SECTION_LABELS: Record<H3PromptSection, string> = {
subject_definitions: 'Subject definitions',
summary: 'Summary',
retention_analysis: 'Retention analysis',
detailed_description: 'Detailed description',
overall_soundscape: 'Overall soundscape',
non_diegetic_music: 'Non-diegetic music',
};
/** Per-section guidance, condensed from the guide's rules for each section. */
export const H3_SECTION_HINTS: Record<H3PromptSection, string> = {
subject_definitions:
'One line per <Subject N>. A subject may draw on several references, e.g. "<Subject 2> is the dog in <Picture 2>, <Picture 3>, and <Picture 4>."',
summary:
'One paragraph, starting with a [task type] prefix: reference generation, keyframe completion, video editing, video continuation, audio reuse, audio reference. Combine with " + ".',
retention_analysis:
'Per subject: "<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - what is retained."',
detailed_description:
'Playback order, using [Shot N] markers, timestamps, (S1) speaker tags and <d>[English] dialogue</d>.',
overall_soundscape: 'Ambience and physical sounds. N/A if none.',
non_diegetic_music:
'Background music audible only to the audience. N/A if none.',
};
/** True when every section is blank. */
export function isEmptyPromptFields(fields: H3PromptFields): boolean {
return H3_PROMPT_SECTIONS.every((s) => !fields[s].trim());
}
/**
* Join the sections into the prompt string the model is given. Blank sections
* become "N/A" rather than being dropped, matching the guide's example.
*/
export function serializeH3Prompt(fields: H3PromptFields): string {
return H3_PROMPT_SECTIONS.map((section) => {
const body = fields[section].trim() || 'N/A';
return `${section}:\n${body}`;
}).join('\n\n');
}
/**
* Split a serialized prompt back into sections, so switching between the
* guided fields and the raw editor does not lose work. Returns null when the
* text is not in section format (a plain prompt, say).
*/
export function parseH3Prompt(text: string): H3PromptFields | null {
const fields = { ...EMPTY_H3_PROMPT_FIELDS };
const headings = new Set<string>(H3_PROMPT_SECTIONS);
let current: H3PromptSection | null = null;
let found = false;
for (const line of text.split('\n')) {
const heading = line.trim().replace(/:$/, '');
if (line.trim().endsWith(':') && headings.has(heading)) {
current = heading as H3PromptSection;
found = true;
continue;
}
if (current) fields[current] += (fields[current] ? '\n' : '') + line;
}
if (!found) return null;
for (const section of H3_PROMPT_SECTIONS) {
fields[section] = fields[section].trim();
}
return fields;
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
labelReferences,
referencePromptSeed,
validateReferences,
type H3Reference,
} from "@/lib/h3References";
const ref = (media_type: H3Reference["media_type"], n: number): H3Reference => ({
id: `${media_type}-${n}`,
source: `/tmp/${media_type}${n}`,
media_type,
fileName: `${media_type}${n}`,
});
describe("labelReferences", () => {
it("numbers each media type independently, in list order", () => {
expect(
labelReferences([ref("image", 1), ref("video", 1), ref("image", 2)]),
).toEqual(["<Picture 1>", "<Video 1>", "<Picture 2>"]);
});
});
describe("validateReferences", () => {
it("accepts an empty list and a normal mix", () => {
expect(validateReferences([])).toBeNull();
expect(validateReferences([ref("image", 1), ref("audio", 1)])).toBeNull();
});
it("rejects audio-only lists", () => {
expect(validateReferences([ref("audio", 1)])).toMatch(/paired/);
});
it("enforces the per-type caps", () => {
const videos = [1, 2, 3, 4].map((n) => ref("video", n));
expect(validateReferences(videos)).toMatch(/At most 3 video/);
});
it("enforces the overall cap", () => {
const many = Array.from({ length: 13 }, (_, i) => ref("image", i));
expect(validateReferences(many)).toMatch(/At most 12/);
});
});
describe("referencePromptSeed", () => {
it("cites the real labels and never indents content", () => {
const seed = referencePromptSeed([ref("image", 1), ref("video", 1)]);
expect(seed.subject_definitions).toContain("<Picture 1>");
expect(seed.subject_definitions).toContain("<Video 1>");
for (const value of Object.values(seed)) {
expect(value).not.toMatch(/^ {2}\S/m);
}
});
it("uses the guide's retention_analysis form", () => {
const seed = referencePromptSeed([ref("image", 1)]);
expect(seed.retention_analysis).toMatch(/fully_preserved - /);
});
it("starts the summary with a bracketed task type", () => {
const seed = referencePromptSeed([ref("image", 1)]);
expect(seed.summary).toMatch(/^\[[a-z +]+\]/);
});
it("describes audio references separately", () => {
const seed = referencePromptSeed([ref("image", 1), ref("audio", 1)]);
expect(seed.subject_definitions).toContain("<Audio 1>");
expect(seed.retention_analysis).toContain("<Audio 1>: reference -");
});
});
@@ -0,0 +1,90 @@
/**
* MiniMax-H3 Ref2VA reference helpers. Labels mirror the per-type counters in
* `build_ref2va_presentation`, so what the user sees is what the model is shown.
*/
import type { MediaType } from "@/lib/api";
export interface H3Reference {
/** stable key for React lists */
id: string;
source: string;
media_type: MediaType;
fileName: string;
}
/** Per-media-type caps enforced by validate_references (reference.py). */
export const H3_REFERENCE_LIMITS: Record<MediaType, number> = {
image: 9,
video: 3,
audio: 3,
};
export const H3_MAX_REFERENCES = 12;
const LABEL_FOR: Record<MediaType, string> = {
image: "Picture",
video: "Video",
audio: "Audio",
};
/** Label each reference the way the pipeline will, e.g. "<Picture 2>". */
export function labelReferences(refs: H3Reference[]): string[] {
const counts: Record<MediaType, number> = { image: 0, video: 0, audio: 0 };
return refs.map((ref) => {
counts[ref.media_type] += 1;
return `<${LABEL_FOR[ref.media_type]} ${counts[ref.media_type]}>`;
});
}
/** Human-readable reason the list is invalid, or null when it is acceptable. */
export function validateReferences(refs: H3Reference[]): string | null {
if (refs.length === 0) return null;
if (refs.length > H3_MAX_REFERENCES) {
return `At most ${H3_MAX_REFERENCES} references (have ${refs.length}).`;
}
const counts: Record<MediaType, number> = { image: 0, video: 0, audio: 0 };
for (const ref of refs) counts[ref.media_type] += 1;
for (const type of Object.keys(counts) as MediaType[]) {
if (counts[type] > H3_REFERENCE_LIMITS[type]) {
return `At most ${H3_REFERENCE_LIMITS[type]} ${type} references (have ${counts[type]}).`;
}
}
if (counts.audio === refs.length) {
return "Audio references must be paired with at least one image or video.";
}
return null;
}
/** Seed the guided prompt fields from the current reference list. */
export function referencePromptSeed(
refs: H3Reference[],
): Record<string, string> {
const labels = labelReferences(refs);
const visual = labels.filter((l) => !l.startsWith("<Audio"));
const audio = labels.filter((l) => l.startsWith("<Audio"));
const subjects = visual.map(
(label, i) =>
`<Subject ${i + 1}> is the subject from ${label}; describe appearance and distinguishing features.`,
);
for (const label of audio) {
subjects.push(`${label} is the audio reference; describe what it provides.`);
}
const retention = visual.map(
(_, i) =>
`<Subject ${i + 1}> (appears in [Shot 1]): fully_preserved - what is retained.`,
);
for (const label of audio) {
retention.push(`${label}: reference - how it guides the audio.`);
}
return {
subject_definitions: subjects.join("\n"),
summary: "[reference generation] Describe the target video and each reference's role.",
retention_analysis: retention.join("\n"),
detailed_description:
"[Shot 1] Describe composition, subjects, environment, lighting, action and camera movement, saying where each reference takes effect.",
overall_soundscape: "",
non_diegetic_music: "",
};
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { jobToFormFields, referenceFileName, type JobLike } from "@/lib/jobToFields";
const job: JobLike = {
id: "abc",
model_id: "MiniMaxAI/MiniMax-H3",
prompt: "subject_definitions:\n<Subject 1> is a dog.\n\nsummary:\n[reference generation] x",
workload_type: "i2v",
references: [
{ source: "/uploads/9f/wukong_source.mp4", media_type: "video" },
{ source: "/uploads/2a/MonkeyKing_0.jpg", media_type: "image" },
],
num_frames: 141,
height: 768,
width: 1344,
guidance_scale: 1.0,
guidance_rescale: 0.1,
seed: 0,
num_gpus: 4,
use_fsdp_inference: true,
};
describe("jobToFormFields", () => {
it("carries the settings that differ from form defaults", () => {
const f = jobToFormFields(job);
expect(f.numFrames).toBe(141);
expect(f.height).toBe(768);
expect(f.width).toBe(1344);
expect(f.guidanceScale).toBe(1.0);
expect(f.guidanceRescale).toBe(0.1);
expect(f.seed).toBe(0); // 0 must survive, not fall back to 1024
expect(f.numGpus).toBe(4);
expect(f.useFsdpInference).toBe(true);
});
it("does not let falsy-but-valid values fall through to defaults", () => {
const f = jobToFormFields({ ...job, seed: 0, vsa_sparsity: 0, guidance_rescale: 0 });
expect(f.seed).toBe(0);
expect(f.vsaSparsity).toBe(0);
expect(f.guidanceRescale).toBe(0);
});
it("rebuilds the reference list with readable names", () => {
const f = jobToFormFields(job);
expect(f.references).toHaveLength(2);
expect(f.references[0].fileName).toBe("wukong_source.mp4");
expect(f.references[1].media_type).toBe("image");
expect(new Set(f.references.map((r) => r.id)).size).toBe(2);
});
it("splits a six-section prompt back into fields", () => {
const f = jobToFormFields(job);
expect(f.promptFields?.subject_definitions).toBe("<Subject 1> is a dog.");
expect(f.promptFields?.summary).toBe("[reference generation] x");
});
it("returns null promptFields for a plain prompt", () => {
const f = jobToFormFields({ ...job, prompt: "a toy car" });
expect(f.promptFields).toBeNull();
expect(f.prompt).toBe("a toy car");
});
it("handles a job with no references", () => {
const f = jobToFormFields({ ...job, references: null });
expect(f.references).toEqual([]);
});
});
describe("referenceFileName", () => {
it("takes the basename", () => {
expect(referenceFileName("/a/b/c.mp4")).toBe("c.mp4");
expect(referenceFileName("c.mp4")).toBe("c.mp4");
});
});
@@ -0,0 +1,119 @@
/**
* Map a persisted job back onto the create-job form's fields. Every value the
* form seeds from defaults must be covered here, or edit mode silently shows
* defaults for whatever is missing.
*/
import type { H3Reference } from "@/lib/h3References";
import { parseH3Prompt, type H3PromptFields } from "@/lib/h3Prompt";
export interface JobLike {
id: string;
model_id: string;
name?: string;
prompt: string;
workload_type?: string;
job_type?: string;
image_path?: string;
last_image_path?: string;
references?: { source: string; media_type: string }[] | null;
negative_prompt?: string;
num_inference_steps?: number;
num_frames?: number;
height?: number;
width?: number;
guidance_scale?: number;
guidance_rescale?: number;
fps?: number;
seed?: number;
num_gpus?: number;
dit_cpu_offload?: boolean;
dit_layerwise_offload?: boolean;
text_encoder_cpu_offload?: boolean;
vae_cpu_offload?: boolean;
image_encoder_cpu_offload?: boolean;
use_fsdp_inference?: boolean;
enable_torch_compile?: boolean;
vsa_sparsity?: number;
tp_size?: number;
sp_size?: number;
}
export interface JobFormFields {
modelId: string;
name: string;
workloadType: string;
jobType: string;
prompt: string;
negativePrompt: string;
imagePath: string;
lastImagePath: string;
references: H3Reference[];
promptFields: H3PromptFields | null;
numInferenceSteps: number;
numFrames: number;
height: number;
width: number;
guidanceScale: number;
guidanceRescale: number;
fps: number;
seed: number;
numGpus: number;
ditCpuOffload: boolean;
ditLayerwiseOffload: boolean;
textEncoderCpuOffload: boolean;
vaeCpuOffload: boolean;
imageEncoderCpuOffload: boolean;
useFsdpInference: boolean;
enableTorchCompile: boolean;
vsaSparsity: number;
tpSize: number;
spSize: number;
}
/** Uploads keep their original basename, so this is the display name. */
export function referenceFileName(source: string): string {
return source.split("/").filter(Boolean).pop() ?? source;
}
export function jobToFormFields(job: JobLike): JobFormFields {
const refs: H3Reference[] = (job.references ?? []).map((r, i) => ({
id: `${job.id}-${i}`,
source: r.source,
media_type: r.media_type as H3Reference["media_type"],
fileName: referenceFileName(r.source),
}));
return {
modelId: job.model_id,
name: job.name ?? "",
workloadType: job.workload_type ?? "t2v",
jobType: job.job_type ?? "inference",
prompt: job.prompt ?? "",
negativePrompt: job.negative_prompt ?? "",
imagePath: job.image_path ?? "",
lastImagePath: job.last_image_path ?? "",
references: refs,
// null when the prompt is not in six-section form; the caller then keeps
// the raw editor rather than silently dropping content into fields.
promptFields: parseH3Prompt(job.prompt ?? ""),
numInferenceSteps: job.num_inference_steps ?? 50,
numFrames: job.num_frames ?? 81,
height: job.height ?? 480,
width: job.width ?? 832,
guidanceScale: job.guidance_scale ?? 5.0,
guidanceRescale: job.guidance_rescale ?? 0.0,
fps: job.fps ?? 24,
seed: job.seed ?? 1024,
numGpus: job.num_gpus ?? 1,
ditCpuOffload: job.dit_cpu_offload ?? false,
ditLayerwiseOffload: job.dit_layerwise_offload ?? false,
textEncoderCpuOffload: job.text_encoder_cpu_offload ?? false,
vaeCpuOffload: job.vae_cpu_offload ?? false,
imageEncoderCpuOffload: job.image_encoder_cpu_offload ?? false,
useFsdpInference: job.use_fsdp_inference ?? false,
enableTorchCompile: job.enable_torch_compile ?? false,
vsaSparsity: job.vsa_sparsity ?? 0,
tpSize: job.tp_size ?? -1,
spSize: job.sp_size ?? -1,
};
}
+1
View File
@@ -5,6 +5,7 @@ export type JobType = "inference" | "finetuning" | "distillation";
export interface Job {
id: string;
model_id: string;
name?: string;
prompt: string;
job_type?: JobType;
workload_type?: string;
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
"""Duplicating a job's config, and editing one that has not started."""
import uuid
import pytest
from fastvideo_studio.database import Database
from fastvideo_studio.job_runner import JobRunner, JobStatus
@pytest.fixture
def runner(tmp_path):
return JobRunner(
output_dir=str(tmp_path / "out"),
log_dir=str(tmp_path / "logs"),
database=Database(tmp_path / "t.db"),
)
def _make(runner, **over):
kwargs = dict(
job_id=str(uuid.uuid4()),
model_id="MiniMaxAI/MiniMax-H3",
prompt="p",
workload_type="i2v",
num_frames=141,
guidance_scale=1.0,
num_gpus=4,
references=[{"source": "/x/clip.mp4", "media_type": "video"}],
)
kwargs.update(over)
return runner.create_job(**kwargs)
def test_duplicate_copies_config_but_not_runtime_state(runner):
src = _make(runner)
src.status = JobStatus.COMPLETED
src.output_path = "/x/out.mp4"
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
assert dup.id != src.id
assert dup.status is JobStatus.PENDING
assert dup.output_path is None
for field in ("model_id", "prompt", "workload_type", "num_frames",
"guidance_scale", "num_gpus", "references"):
assert getattr(dup, field) == getattr(src, field)
def test_duplicate_deep_copies_references(runner):
src = _make(runner)
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
dup.references[0]["source"] = "/changed"
assert src.references[0]["source"] == "/x/clip.mp4"
def test_duplicate_unknown_job(runner):
with pytest.raises(ValueError, match="not found"):
runner.duplicate_job("nope", str(uuid.uuid4()))
def test_edit_pending_job(runner):
job = _make(runner)
updated = runner.update_job_config(job.id, {"num_frames": 192, "seed": 7})
assert updated.num_frames == 192
assert updated.seed == 7
@pytest.mark.parametrize("status", [JobStatus.FAILED, JobStatus.STOPPED])
def test_edit_allows_restartable_jobs(runner, status):
"""Editable exactly when startable: neither has produced an output."""
job = _make(runner)
job.status = status
assert runner.update_job_config(job.id, {"seed": 7}).seed == 7
@pytest.mark.parametrize("status", [JobStatus.COMPLETED, JobStatus.RUNNING])
def test_edit_rejects_jobs_with_or_producing_a_result(runner, status):
job = _make(runner)
job.status = status
with pytest.raises(ValueError, match="can be edited"):
runner.update_job_config(job.id, {"seed": 7})
def test_edit_rejects_unknown_field(runner):
job = _make(runner)
with pytest.raises(ValueError, match="Not editable"):
runner.update_job_config(job.id, {"status": "completed"})
def test_name_is_carried_by_duplicate(runner):
src = _make(runner, name="wukong swap v2")
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
assert dup.name == "wukong swap v2"
def test_name_is_editable(runner):
job = _make(runner, name="a")
assert runner.update_job_config(job.id, {"name": "b"}).name == "b"
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: Apache-2.0
"""Uploaded files keep a readable basename under a unique directory."""
from __future__ import annotations
import pytest
from fastvideo_studio.server import _safe_upload_name
@pytest.mark.parametrize(
("filename", "ext", "expected"),
[
("wukong_source.mp4", ".mp4", "wukong_source.mp4"),
("MonkeyKing_0.jpg", ".jpg", "MonkeyKing_0.jpg"),
("my clip (final).mp4", ".mp4", "my_clip_final.mp4"),
("../../etc/passwd.png", ".png", "passwd.png"),
("/abs/path/frame.png", ".png", "frame.png"),
("émoji✨.png", ".png", "moji.png"),
("", ".png", "upload.png"),
(None, ".png", "upload.png"),
("...", ".png", "upload.png"),
],
)
def test_safe_upload_name(filename, ext, expected):
assert _safe_upload_name(filename, ext) == expected
def test_long_names_are_capped():
out = _safe_upload_name("x" * 300 + ".png", ".png")
assert out == "x" * 80 + ".png"
def test_no_path_separators_survive():
for bad in ("a/b.png", "a\\b.png", "../x.png"):
assert "/" not in _safe_upload_name(bad, ".png")
assert "\\" not in _safe_upload_name(bad, ".png")
+641 -13
View File
@@ -1,52 +1,680 @@
{
"version": 6,
"recipes": [
{
"id": "fastwan21-t2v",
"family": "wan",
"stage": "inference",
"task": "Text to video",
"label": "FastWan2.1 1.3B (distilled + VSA)",
"summary": "Generate a video in three denoising steps with the distilled FastWan2.1 1.3B checkpoint and video sparse attention.",
"model": "FastVideo/FastWan2.1-T2V-1.3B-Diffusers",
"source": "scripts/inference/inference_wan_VSA_DMD_1_3B.yaml",
"command": "FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN fastvideo generate --config scripts/inference/inference_wan_VSA_DMD_1_3B.yaml"
"command": "FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN fastvideo generate --config scripts/inference/inference_wan_VSA_DMD_1_3B.yaml",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under outputs_video_dmd_1.3B/"
},
{
"id": "wan22-t2v",
"family": "wan",
"stage": "inference",
"task": "Text to video",
"label": "Wan2.2 A14B",
"summary": "The maintained high-capacity Wan2.2 text-to-video example with CPU offload settings encoded in its checked-in Python source.",
"model": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"source": "examples/inference/basic/basic_wan2_2.py",
"command": "python examples/inference/basic/basic_wan2_2.py"
"command": "python examples/inference/basic/basic_wan2_2.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 2, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_wan2_2_14B_t2v/"
},
{
"id": "wan21-i2v",
"family": "wan",
"stage": "inference",
"task": "Image to video",
"label": "Wan2.1 14B 480P",
"summary": "Animate an input image at 480P using the maintained Wan2.1 YAML configuration and its recorded offload settings.",
"model": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"source": "scripts/inference/inference_wan_i2v.yaml",
"command": "fastvideo generate --config scripts/inference/inference_wan_i2v.yaml"
},
{
"id": "turbowan22-i2v",
"task": "Image to video",
"label": "TurboWan2.2 A14B",
"model": "loayrashid/TurboWan2.2-I2V-A14B-Diffusers",
"source": "examples/inference/basic/basic_turbodiffusion_i2v.py",
"command": "python examples/inference/basic/basic_turbodiffusion_i2v.py"
"command": "fastvideo generate --config scripts/inference/inference_wan_i2v.yaml",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 2, "evidence": "source-configured"},
"evidence": "Source-backed"
},
{
"id": "wan22-ti2v",
"family": "wan",
"stage": "inference",
"task": "Text or image to video",
"label": "Wan2.2 TI2V 5B",
"summary": "Use one maintained 5B checkpoint for text-to-video or add an image input to switch the same recipe to image-to-video.",
"model": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
"source": "examples/inference/basic/basic_wan2_2_ti2v.py",
"command": "python examples/inference/basic/basic_wan2_2_ti2v.py"
"command": "python examples/inference/basic/basic_wan2_2_ti2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_wan2_2_5B_ti2v/"
},
{
"id": "fastmetal-1-3b-mlx",
"family": "wan",
"stage": "inference",
"task": "Text to video",
"label": "FastMetal 1.3B",
"summary": "Run the released FastMetal 1.3B QAD checkpoint through FastVideo's native Apple Silicon MLX path.",
"model": "FastVideo/FastMetal-1.3B-QAD",
"source": "examples/inference/basic/mlx_wan_prompt_to_video.py",
"command": "hf download FastVideo/FastMetal-1.3B-QAD --local-dir ./FastMetal-1.3B-QAD\npython examples/inference/basic/mlx_wan_prompt_to_video.py --model-root ./FastMetal-1.3B-QAD --mlx-checkpoint ./FastMetal-1.3B-QAD --height 480 --width 832 --num-frames 81 --prompt \"A bird's-eye view of a misty forest valley at dawn.\" --output-path ./outputs/fastmetal_1_3b.mp4",
"gpu_types": ["Apple Silicon"],
"hardware": {
"platform": "mlx",
"accelerator": "Apple M4 Max",
"system_memory": "36 GB unified memory",
"minimum_memory": "16 GB+ unified memory",
"peak_memory": "3.87 GiB peak MLX memory",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1638"
},
"evidence": "Verified",
"expected_artifact": "MP4 video at outputs/fastmetal_1_3b.mp4",
"modes": ["T2V", "temporal --fast", "spatial --fast-spatial", "two-pass --refine"],
"limitations": [
"Native MLX FastMetal T2V. Add --fast for temporal RIFE, --fast-spatial to denoise at half resolution, or --refine for a two-pass upsample. basic_mps.py is the older PyTorch MPS demo."
]
},
{
"id": "fastmetal-5b-mlx",
"family": "wan",
"stage": "inference",
"task": "Text to video",
"label": "FastMetal 5B",
"summary": "Run the released Wan2.2 5B FastMetal checkpoint as MLX T2V with MLX DiT denoising and MLX TAEHV decode. The CUDA Wan2.2 TI2V 5B recipe is the image-capable path.",
"model": "FastVideo/FastMetal-5B-QAD",
"source": "examples/inference/basic/mlx_wan22_generate.py",
"command": "hf download FastVideo/FastMetal-5B-QAD --local-dir ./FastMetal-5B-QAD\npython examples/inference/basic/mlx_wan22_generate.py --mlx-checkpoint ./FastMetal-5B-QAD --text-encoder-root ./FastMetal-5B-QAD --vae-root ./FastMetal-5B-QAD/vae --height 704 --width 1280 --num-frames 81 --prompt \"A cinematic portrait with soft neon lighting and smooth camera motion.\" --output-path ./outputs/fastmetal_5b.mp4",
"gpu_types": ["Apple Silicon"],
"hardware": {
"platform": "mlx",
"accelerator": "Apple M4 Max",
"system_memory": "36 GB unified memory",
"minimum_memory": "16 GB+ unified memory",
"peak_memory": "9.34 GiB peak MLX memory",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1638"
},
"evidence": "Verified",
"expected_artifact": "MP4 video at outputs/fastmetal_5b.mp4",
"modes": ["T2V", "temporal --fast", "spatial --fast-spatial", "two-pass --refine"],
"limitations": [
"The checked-in MLX example is T2V. Image-to-video is not in mlx_wan22_generate.py. Add --fast, --fast-spatial, or --refine on the same script."
]
},
{
"id": "fastmetal-14b-mlx",
"family": "wan",
"stage": "inference",
"task": "Text to video",
"label": "FastMetal 14B",
"summary": "Run the released 14B FastMetal QAD checkpoint through the same Apple Silicon MLX entrypoint as the 1.3B release.",
"model": "FastVideo/FastMetal-14B-QAD",
"source": "examples/inference/basic/mlx_wan_prompt_to_video.py",
"command": "hf download FastVideo/FastMetal-14B-QAD --local-dir ./FastMetal-14B-QAD\npython examples/inference/basic/mlx_wan_prompt_to_video.py --model-root ./FastMetal-14B-QAD --mlx-checkpoint ./FastMetal-14B-QAD --height 480 --width 832 --num-frames 81 --prompt \"A wide cinematic landscape at sunrise.\" --output-path ./outputs/fastmetal_14b.mp4",
"gpu_types": ["Apple Silicon"],
"hardware": {
"platform": "mlx",
"accelerator": "Apple M4 Max",
"system_memory": "36 GB unified memory",
"minimum_memory": "36 GB+ unified memory",
"peak_memory": "21.68 GiB peak MLX memory",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1638"
},
"evidence": "Verified",
"expected_artifact": "MP4 video at outputs/fastmetal_14b.mp4",
"modes": ["T2V", "temporal --fast", "spatial --fast-spatial", "two-pass --refine"],
"limitations": [
"Native MLX FastMetal T2V on 36 GB+ unified memory. Same --fast, --fast-spatial, and --refine flags as the 1.3B script."
]
},
{
"id": "turbodiffusion-wan21-1-3b-t2v",
"family": "turbodiffusion",
"stage": "inference",
"task": "Text to video",
"label": "TurboWan2.1 1.3B",
"summary": "A TurboDiffusion-accelerated Wan2.1 1.3B text-to-video run from its maintained single-GPU example.",
"model": "loayrashid/TurboWan2.1-T2V-1.3B-Diffusers",
"source": "examples/inference/basic/basic_turbodiffusion.py",
"command": "python examples/inference/basic/basic_turbodiffusion.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_turbodiffusion/",
"related": ["turbodiffusion-wan21-14b-t2v", "turbowan22-i2v"]
},
{
"id": "turbodiffusion-wan21-14b-t2v",
"family": "turbodiffusion",
"stage": "inference",
"task": "Text to video",
"label": "TurboWan2.1 14B",
"summary": "TurboDiffusion acceleration applied to the 14B Wan2.1 text-to-video checkpoint; the checked-in source is configured for two GPUs.",
"model": "loayrashid/TurboWan2.1-T2V-14B-Diffusers",
"source": "examples/inference/basic/basic_turbodiffusion_14b.py",
"command": "python examples/inference/basic/basic_turbodiffusion_14b.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 2, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_turbodiffusion_14B/",
"related": ["turbodiffusion-wan21-1-3b-t2v", "turbowan22-i2v"]
},
{
"id": "turbowan22-i2v",
"family": "turbodiffusion",
"stage": "inference",
"task": "Image to video",
"label": "TurboWan2.2 A14B",
"summary": "A one-to-four-step image-to-video path using TurboDiffusion and the SLA attention backend from its maintained example.",
"model": "loayrashid/TurboWan2.2-I2V-A14B-Diffusers",
"source": "examples/inference/basic/basic_turbodiffusion_i2v.py",
"command": "python examples/inference/basic/basic_turbodiffusion_i2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 2, "evidence": "source-configured"},
"evidence": "Source-backed",
"related": ["turbodiffusion-wan21-14b-t2v"]
},
{
"id": "ltx2-distilled-t2v",
"family": "ltx2",
"stage": "inference",
"task": "Text to video",
"label": "LTX-2 distilled",
"summary": "The distilled LTX-2 text-to-video checkpoint with audio, from its maintained example. The source is configured for four GPUs.",
"model": "FastVideo/LTX2-Distilled-Diffusers",
"source": "examples/inference/basic/basic_ltx2_distilled.py",
"command": "python examples/inference/basic/basic_ltx2_distilled.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 4, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 (video with audio) at outputs_video/ltx2_basic/output_ltx2_distilled_t2v.mp4",
"related": ["ltx23-base-t2v"]
},
{
"id": "ltx23-base-t2v",
"family": "ltx2",
"stage": "inference",
"task": "Text to video",
"label": "LTX-2 base (1088p)",
"summary": "Base LTX-2 text-to-video at 1088x1920 using FastVideo default sampling for LTX2 base. The example loads a community Diffusers mirror of the base checkpoint; registered aliases include Lightricks/LTX-2 and FastVideo/LTX2-Diffusers.",
"model": "Davids048/LTX2-Base-Diffusers",
"source": "examples/inference/basic/basic_ltx2.py",
"command": "python examples/inference/basic/basic_ltx2.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 (video with audio) at outputs_video/ltx2_basic/output_ltx2_base_t2v_1088_1920_1.1.mp4",
"limitations": ["The maintained example loads the Davids048/LTX2-Base-Diffusers community mirror rather than a Lightricks upstream ID."],
"related": ["ltx2-distilled-t2v"]
},
{
"id": "hy15-t2v-480p",
"family": "hunyuan",
"stage": "inference",
"task": "Text to video",
"label": "HunyuanVideo 1.5 480P",
"summary": "HunyuanVideo 1.5 text-to-video at 480P with CPU offload enabled in the checked-in source for smaller GPUs.",
"model": "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v",
"source": "examples/inference/basic/basic_hy15.py",
"command": "python examples/inference/basic/basic_hy15.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_hy15/",
"related": ["hy15-1080p-upscale"]
},
{
"id": "hy15-1080p-upscale",
"family": "hunyuan",
"stage": "inference",
"task": "Text to video (upscaled)",
"label": "HunyuanVideo 1.5 1080P upscale",
"summary": "Run HunyuanVideo 1.5 through the 480p to 720p to 1080p upscale chain in one maintained script.",
"model": "weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR",
"source": "examples/inference/basic/basic_hy15_1080p.py",
"command": "python examples/inference/basic/basic_hy15_1080p.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_hy15_1080p/",
"related": ["hy15-t2v-480p"]
},
{
"id": "cosmos25-t2w",
"family": "cosmos",
"stage": "inference",
"task": "Text to world",
"label": "Cosmos Predict 2.5 2B",
"summary": "Generate a navigable world video from a text prompt with Cosmos Predict 2.5 2B on a single GPU.",
"model": "KyleShao/Cosmos-Predict2.5-2B-Diffusers",
"source": "examples/inference/basic/basic_cosmos2_5_t2w.py",
"command": "python examples/inference/basic/basic_cosmos2_5_t2w.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed"
},
{
"id": "kandinsky5-t2v-lite-sft",
"family": "kandinsky5",
"stage": "inference",
"task": "Text to video",
"label": "Kandinsky 5.0 T2V Lite SFT",
"summary": "Kandinsky 5.0 text-to-video (Lite SFT variant) from the maintained example; alternative Lite/Pro checkpoints are listed in the source.",
"model": "kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers",
"source": "examples/inference/basic/basic_kandinsky5_t2v.py",
"command": "python examples/inference/basic/basic_kandinsky5_t2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA B200",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1471"
},
"evidence": "Verified",
"expected_artifact": "MP4 videos under video_samples_kandinsky5_t2v/",
"related": ["kandinsky5-i2v-pro-distilled"]
},
{
"id": "kandinsky5-i2v-pro-distilled",
"family": "kandinsky5",
"stage": "inference",
"task": "Image to video",
"label": "Kandinsky 5.0 I2V Pro distilled",
"summary": "Animate an input image with Kandinsky 5.0 I2V Pro (distilled) on a single GPU.",
"model": "kandinskylab/Kandinsky-5.0-I2V-Pro-distilled-5s-Diffusers",
"source": "examples/inference/basic/basic_kandinsky5_i2v.py",
"command": "python examples/inference/basic/basic_kandinsky5_i2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA B200",
"peak_memory": "10,365.89 MB peak GPU memory",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1471"
},
"evidence": "Verified",
"expected_artifact": "MP4 videos under video_samples_kandinsky5_i2v/",
"related": ["kandinsky5-t2v-lite-sft"]
},
{
"id": "flux2-klein-t2i",
"family": "flux",
"stage": "inference",
"task": "Text to image",
"label": "FLUX.2 Klein 4B",
"summary": "Generate an image in four denoising steps with the distilled FLUX.2 Klein checkpoint.",
"model": "black-forest-labs/FLUX.2-klein-4B",
"source": "examples/inference/basic/basic_flux2_klein.py",
"command": "python examples/inference/basic/basic_flux2_klein.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG image at outputs/flux2/flux2_klein.png",
"related": ["flux2-dev-t2i"]
},
{
"id": "flux2-dev-t2i",
"family": "flux",
"stage": "inference",
"task": "Text to image",
"label": "FLUX.2 dev",
"summary": "Full FLUX.2 dev text-to-image with embedded guidance and the Mistral3 text encoder, from its maintained example.",
"model": "black-forest-labs/FLUX.2-dev",
"source": "examples/inference/basic/basic_flux2.py",
"command": "python examples/inference/basic/basic_flux2.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG image at outputs/flux2/flux2.png",
"related": ["flux2-klein-t2i"]
},
{
"id": "flux1-dev-t2i",
"family": "flux",
"stage": "inference",
"task": "Text to image",
"label": "FLUX.1 dev",
"summary": "FLUX.1 dev text-to-image through the Diffusers-backed pipeline. The example defaults to a local weights directory, so this recipe passes the Hugging Face ID explicitly.",
"model": "black-forest-labs/FLUX.1-dev",
"source": "examples/inference/basic/basic_flux_dev.py",
"command": "python examples/inference/basic/basic_flux_dev.py --model-path black-forest-labs/FLUX.1-dev",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG images under outputs/flux_dev/samples/",
"limitations": ["FLUX.1 is loadable by ID but registers no model_family in fastvideo/registry.py; it is grouped under FLUX for documentation only."]
},
{
"id": "glm-image-t2i",
"family": "glm_image",
"stage": "inference",
"task": "Text to image",
"label": "GLM-Image",
"summary": "GLM-Image text-to-image generation from its maintained example.",
"model": "zai-org/GLM-Image",
"source": "examples/inference/basic/basic_glm_image.py",
"command": "python examples/inference/basic/basic_glm_image.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG image at image_output/landscape.png",
"related": ["glm-image-edit"]
},
{
"id": "glm-image-edit",
"family": "glm_image",
"stage": "inference",
"task": "Image editing",
"label": "GLM-Image editing",
"summary": "Edit an input image with an instruction prompt using GLM-Image, from its maintained editing example.",
"model": "zai-org/GLM-Image",
"source": "examples/inference/basic/edit_glm_image.py",
"command": "python examples/inference/basic/edit_glm_image.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG image at image_output/edited.png (input: assets/images/couple.jpg)",
"related": ["glm-image-t2i"]
},
{
"id": "zimage-turbo-t2i",
"family": "zimage",
"stage": "inference",
"task": "Text to image",
"label": "Z-Image Turbo",
"summary": "Z-Image Turbo text-to-image on a single GPU from its maintained example.",
"model": "Tongyi-MAI/Z-Image-Turbo",
"source": "examples/inference/basic/basic_zimage.py",
"command": "python examples/inference/basic/basic_zimage.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG image at outputs/zimage/zimage_turbo.png"
},
{
"id": "sd35-medium-t2i",
"family": "sd35",
"stage": "inference",
"task": "Text to image",
"label": "Stable Diffusion 3.5 Medium",
"summary": "Stable Diffusion 3.5 Medium text-to-image over a small built-in prompt set, from its maintained example.",
"model": "stabilityai/stable-diffusion-3.5-medium",
"source": "examples/inference/basic/basic_sd35_t2i.py",
"command": "python examples/inference/basic/basic_sd35_t2i.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "PNG images under outputs/sd35/samples/"
},
{
"id": "minimax-h3-t2v",
"family": "minimax_h3",
"stage": "inference",
"task": "Text to video (with audio)",
"label": "MiniMax H3 T2VA",
"summary": "Generate synchronized video and stereo audio from a structured text prompt with the full MiniMax H3 checkpoint.",
"model": "MiniMaxAI/MiniMax-H3",
"source": "examples/inference/basic/basic_minimax_h3_t2v.py",
"command": "python examples/inference/basic/basic_minimax_h3_t2v.py --prompt \"(S1) A presenter says <d>[English] FastVideo runs MiniMax H3.</d>\"",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 4, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 with synchronized audio at outputs/minimax_h3_t2v/minimax_h3_t2v.mp4",
"modes": ["T2VA"],
"limitations": ["The checked-in example defaults to four-way sequence parallelism. It does not record a GPU model or memory requirement."]
},
{
"id": "fasth3-preview-cuda",
"group": "fasth3-preview",
"group_label": "FastH3 Preview",
"group_task": "4-step text to video + audio",
"family": "minimax_h3",
"stage": "inference",
"task": "Few-step text to video (with audio)",
"label": "FastH3 Preview on CUDA",
"summary": "Run the DMD2-distilled FastH3 Preview with four DiT forwards, trained H3 sparse attention, compiled decode, and synchronized audio.",
"model": "FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2",
"source": "examples/inference/basic/basic_fasth3.py",
"serving": {
"source": "examples/serving/openai_fasth3.yaml",
"install": "UV_TORCH_BACKEND=cu130 uv pip install -e \".[fasth3]\""
},
"command": "UV_TORCH_BACKEND=cu130 uv pip install -e \".[fasth3]\"\npython examples/inference/basic/basic_fasth3.py --prompt \"(S1) A presenter says <d>[English] FastVideo runs FastH3.</d>\" --profile all",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 4,
"accelerator": "NVIDIA GB200",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1731"
},
"evidence": "Verified",
"expected_artifact": "Warmup and measured MP4 files under outputs/fasth3/",
"modes": ["T2VA", "4-step FastH3"],
"limitations": ["The default all profile is the measured GB200 performance route and can change floating-point operation order. Use --profile strict --no-inference-torch-compile for the eager strict route."]
},
{
"id": "fasth3-preview-mlx",
"serving": {
"source": "examples/serving/mlx_fasth3.yaml",
"install": "uv pip install -e \".[mlx]\"",
"prepare": "hf download FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2 --local-dir ./FastH3-Preview-v0.2\npython scripts/checkpoint_conversion/convert_minimax_h3_mlx.py --model-root ./FastH3-Preview-v0.2/transformer --out ./FastH3-MLX --formats \"int6\""
},
"group": "fasth3-preview",
"group_label": "FastH3 Preview",
"group_task": "4-step text to video + audio",
"family": "minimax_h3",
"stage": "inference",
"task": "Few-step text to video (with audio)",
"label": "FastH3 Preview on MLX",
"summary": "Run FastH3 Preview on Apple Silicon with a locally converted INT6 DiT, streamed Qwen3-VL conditioning, and native MLX video and audio VAEs.",
"model": "FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2",
"source": "examples/inference/basic/mlx_fasth3.py",
"command": "hf download FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2 --local-dir ./FastH3-Preview-v0.2\npython scripts/checkpoint_conversion/convert_minimax_h3_mlx.py --model-root ./FastH3-Preview-v0.2/transformer --out ./FastH3-MLX --formats \"int6\"\npython examples/inference/basic/mlx_fasth3.py --model-root ./FastH3-Preview-v0.2 --mlx-checkpoint ./FastH3-MLX/int6 --prompt \"(S1) A presenter says <d>[English] FastVideo runs FastH3.</d>\" --height 480 --width 832 --num-frames 124 --seed 2026 --output-path ./outputs/fasth3_int6.mp4",
"gpu_types": ["Apple Silicon"],
"hardware": {
"platform": "mlx",
"accelerator": "Apple M4 Max",
"system_memory": "36 GB unified memory",
"peak_memory": "19.63 GiB peak MLX memory during denoising",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1770"
},
"evidence": "Verified",
"expected_artifact": "MP4 with H.264 video and stereo AAC audio at outputs/fasth3_int6.mp4",
"modes": ["T2VA", "temporal --fast", "spatial --fast-spatial", "opt-in VSA"],
"limitations": [
"The MLX path supports T2VA, optional temporal --fast, optional spatial --fast-spatial, and opt-in VSA on --include-vsa checkpoints. FL2VA, Ref2VA, and two-pass refinement are not wired."
]
},
{
"id": "minimax-h3-fl2va",
"family": "minimax_h3",
"stage": "inference",
"task": "First/last frame to video (with audio)",
"label": "MiniMax H3 FL2VA",
"summary": "Animate a first frame, optionally guide the final frame, and generate synchronized audio with the full MiniMax H3 checkpoint.",
"model": "MiniMaxAI/MiniMax-H3",
"source": "examples/inference/basic/basic_minimax_h3_fl2va.py",
"command": "python examples/inference/basic/basic_minimax_h3_fl2va.py --image path/to/first-frame.png --prompt \"(S1) The subject turns toward the camera and says <d>[English] Hello.</d>\"",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 4, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 with synchronized audio at outputs/minimax_h3_fl2va/minimax_h3_fl2va.mp4",
"modes": ["FL2VA"],
"limitations": ["Pass --last-image to constrain the final frame. The checked-in source defaults to four GPUs."]
},
{
"id": "minimax-h3-ref2va",
"family": "minimax_h3",
"stage": "inference",
"task": "Reference media to video (with audio)",
"label": "MiniMax H3 Ref2VA",
"summary": "Condition H3 on an ordered reference video and optional audio reference, then generate a new synchronized video and audio result.",
"model": "MiniMaxAI/MiniMax-H3",
"source": "examples/inference/basic/basic_minimax_h3_ref2va.py",
"command": "python examples/inference/basic/basic_minimax_h3_ref2va.py --reference-video path/to/reference.mp4 --prompt \"Create a new scene that preserves the reference identity and motion language.\"",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 4, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 with synchronized audio at outputs/minimax_h3_ref2va/minimax_h3_ref2va.mp4",
"modes": ["Ref2VA"],
"limitations": ["Pass --reference-audio for an additional audio reference. The checked-in source defaults to four GPUs."]
},
{
"id": "fasth3-lora-preview",
"family": "minimax_h3",
"stage": "inference",
"task": "LoRA-adapted few-step video (with audio)",
"label": "FastH3 LoRA Preview",
"summary": "Apply a FastH3 preview adapter at load time while keeping the shared four-forward performance profile and synchronized audio output.",
"model": "MiniMaxAI/MiniMax-H3",
"source": "examples/inference/basic/basic_fasth3_lora_preview.py",
"command": "python examples/inference/basic/basic_fasth3_lora_preview.py --lora-path path/to/adapter.safetensors --prompt \"(S1) A presenter says <d>[English] This is an adapted Fast H3 run.</d>\"",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 4,
"accelerator": "NVIDIA B200",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1771"
},
"evidence": "Verified",
"expected_artifact": "Warmup and measured MP4 files under outputs/fasth3_lora_preview/",
"modes": ["T2VA", "FastH3 LoRA"],
"limitations": ["Supply a compatible FastH3 adapter. The script infers dense or VSA attention from the adapter payload unless you override it."]
},
{
"id": "longcat-t2v",
"family": "longcat",
"stage": "inference",
"task": "Text to video",
"label": "LongCat Video T2V",
"summary": "LongCat Video text-to-video at 480p (50 steps), with distilled and 720p refinement passes included in the same maintained script.",
"model": "FastVideo/LongCat-Video-T2V-Diffusers",
"source": "examples/inference/basic/basic_longcat_t2v.py",
"command": "python examples/inference/basic/basic_longcat_t2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under outputs_video/longcat_t2v_basic/, longcat_t2v_distill/, and longcat_t2v_refine_720p/",
"related": ["longcat-i2v"]
},
{
"id": "longcat-i2v",
"family": "longcat",
"stage": "inference",
"task": "Image to video",
"label": "LongCat Video I2V",
"summary": "LongCat Video image-to-video with optional distilled and refinement passes, from its maintained example.",
"model": "FastVideo/LongCat-Video-I2V-Diffusers",
"source": "examples/inference/basic/basic_longcat_i2v.py",
"command": "python examples/inference/basic/basic_longcat_i2v.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under outputs_video/longcat_i2v_basic/ and longcat_i2v_distill/",
"related": ["longcat-t2v"]
},
{
"id": "stable-audio-open-t2a",
"family": "stable_audio",
"stage": "inference",
"task": "Text to audio",
"label": "Stable Audio Open 1.0",
"summary": "Six-second text-to-audio generation with Stable Audio Open 1.0 from its maintained example; duration and steps are documented knobs in the source.",
"model": "FastVideo/stable-audio-open-1.0-Diffusers",
"source": "examples/inference/basic/basic_stable_audio.py",
"command": "python examples/inference/basic/basic_stable_audio.py",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA B200",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/1260"
},
"evidence": "Verified",
"expected_artifact": "WAV audio at outputs_audio/stable_audio_basic/output_stable_audio.wav",
"limitations": ["Must load the FastVideo converted Diffusers repo; upstream stabilityai monolithic checkpoints are not loader-compatible (see scripts/checkpoint_conversion/stable_audio_to_diffusers.py)."],
"related": ["stable-audio-small-t2a"]
},
{
"id": "stable-audio-small-t2a",
"family": "stable_audio",
"stage": "inference",
"task": "Text to audio",
"label": "Stable Audio Open Small",
"summary": "The smaller Stable Audio Open variant with its own shorter training window, from its maintained example.",
"model": "FastVideo/stable-audio-open-small-Diffusers",
"source": "examples/inference/basic/basic_stable_audio_small.py",
"command": "python examples/inference/basic/basic_stable_audio_small.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"related": ["stable-audio-open-t2a"]
},
{
"id": "mmaudio-v2a",
"family": "mmaudio",
"stage": "inference",
"task": "Video/Text to audio",
"label": "MMAudio large 44k v2",
"summary": "Add synchronized audio to a video (or from a prompt) with MMAudio large 44k v2. The example reads the model path from MMAUDIO_MODEL_PATH; this recipe passes the converted Hugging Face repo explicitly.",
"model": "FastVideo/MMAudio-large-44k-v2-Diffusers",
"source": "examples/inference/basic/basic_mmaudio.py",
"command": "MMAUDIO_MODEL_PATH=FastVideo/MMAudio-large-44k-v2-Diffusers python examples/inference/basic/basic_mmaudio.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"limitations": ["The upstream checkpoint must be converted to Diffusers layout via scripts/checkpoint_conversion/convert_mmaudio_to_diffusers.py unless loaded from the FastVideo converted repo as done here."]
},
{
"id": "matrix-game-2",
"family": "matrixgame",
"stage": "inference",
"task": "Interactive world",
"label": "Matrix Game 2.0",
"summary": "Generate an interactive-world sequence from the maintained Matrix Game 2.0 example.",
"model": "FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers",
"source": "examples/inference/basic/basic_matrixgame2.py",
"command": "python examples/inference/basic/basic_matrixgame2.py"
"command": "python examples/inference/basic/basic_matrixgame2.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"related": ["matrix-game-3-i2w"]
},
{
"id": "matrix-game-3-i2w",
"family": "matrixgame",
"stage": "inference",
"task": "Interactive world",
"label": "Matrix Game 3.0",
"summary": "Drive Matrix Game 3.0 from an input image plus prompt at 720p, three steps, from its maintained example.",
"model": "FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers",
"source": "examples/inference/basic/basic_matrixgame3.py",
"command": "python examples/inference/basic/basic_matrixgame3.py",
"gpu_types": ["NVIDIA"],
"hardware": {"gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 videos under video_samples_matrixgame3/",
"related": ["matrix-game-2"]
}
]
}
+499 -44
View File
@@ -1,57 +1,512 @@
(() => {
let recipesPromise;
const PATTERN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const PATTERN_LENGTH = 900;
const SCRAMBLE_MS = 140;
const loadRecipes = (url) => {
recipesPromise ||= fetch(url).then((response) => {
const loadRecipes = (url) =>
fetch(url).then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
return recipesPromise;
const generatePattern = (length) => {
const chars = new Array(length);
for (let index = 0; index < length; index += 1) {
chars[index] = PATTERN_CHARS.charAt((Math.random() * PATTERN_CHARS.length) | 0);
}
return chars.join("");
};
const motionQuery = () =>
window.matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)");
const initEvervault = (root) => {
root.querySelectorAll("[data-evervault]").forEach((visual) => {
if (visual.dataset.evervaultReady) return;
visual.dataset.evervaultReady = "true";
const noise = visual.querySelector("[data-cookbook-pattern]");
if (!noise) return;
const query = motionQuery();
if (!query.matches) return;
let rect = null;
let pointerX = 0;
let pointerY = 0;
let frame = 0;
let lastScramble = 0;
let patternReady = false;
const paint = (scramble) => {
visual.style.setProperty("--mouse-x", `${pointerX}px`);
visual.style.setProperty("--mouse-y", `${pointerY}px`);
if (scramble) noise.textContent = generatePattern(PATTERN_LENGTH);
frame = 0;
};
const onEnter = () => {
rect = visual.getBoundingClientRect();
if (!patternReady) {
noise.textContent = generatePattern(PATTERN_LENGTH);
patternReady = true;
}
};
const onMove = (event) => {
if (!rect) rect = visual.getBoundingClientRect();
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
if (frame) return;
frame = window.requestAnimationFrame(() => {
const now = performance.now();
const scramble = now - lastScramble >= SCRAMBLE_MS;
if (scramble) lastScramble = now;
paint(scramble);
});
};
const onLeave = () => {
if (frame) window.cancelAnimationFrame(frame);
frame = 0;
rect = null;
pointerX = 0;
pointerY = 0;
visual.style.setProperty("--mouse-x", "50%");
visual.style.setProperty("--mouse-y", "50%");
};
visual.addEventListener("mouseenter", onEnter);
visual.addEventListener("mousemove", onMove);
visual.addEventListener("mouseleave", onLeave);
});
};
let familyPopstate = null;
const bindFamilyPopstate = () => {
if (bindFamilyPopstate.bound) return;
bindFamilyPopstate.bound = true;
window.addEventListener("popstate", () => {
if (typeof familyPopstate === "function") familyPopstate();
});
};
const groupIdFor = (recipe) => recipe.group || recipe.id;
const gpuCountLabel = (hardware) => {
const count = hardware?.gpu_count;
return `${count} GPU${count === 1 ? "" : "s"}`;
};
const runtimeFor = (recipe) => {
const platform = recipe.hardware?.platform || "cuda";
if (platform === "mlx") {
return {
id: "mlx",
label: "Apple Silicon · MLX",
hint:
recipe.hardware?.minimum_memory ||
[recipe.hardware?.accelerator, recipe.hardware?.system_memory].filter(Boolean).join(" · ") ||
"Memory not recorded",
};
}
if (platform === "mps") {
return {
id: "mps",
label: "Apple Silicon · MPS",
hint: recipe.hardware?.minimum_memory || recipe.hardware?.system_memory || "Memory not recorded",
};
}
const hardware = recipe.hardware || {};
return {
id: "cuda",
label: "NVIDIA CUDA",
hint: hardware.accelerator
? `${hardware.accelerator} · ${gpuCountLabel(hardware)}`
: `${gpuCountLabel(hardware)} configured · GPU model not recorded`,
};
};
const runtimeSummary = (recipe) => {
const runtime = runtimeFor(recipe);
const hardware = recipe.hardware || {};
if (runtime.id === "mlx") {
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "MLX"]
.filter(Boolean)
.join(" · ");
}
if (runtime.id === "mps") {
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "PyTorch MPS"]
.filter(Boolean)
.join(" · ");
}
if (hardware.accelerator) return [hardware.accelerator, gpuCountLabel(hardware)].join(" · ");
return `NVIDIA CUDA · ${gpuCountLabel(hardware)} configured · GPU model and VRAM not recorded`;
};
const renderHardwareEvidence = (container, badge, recipe) => {
const hardware = recipe.hardware || {};
const runtime = runtimeFor(recipe);
const isValidated = hardware.evidence === "validated";
container.classList.toggle("cookbook-hardware-state--verified", isValidated);
container.classList.toggle("cookbook-hardware-state--source", !isValidated);
badge.classList.toggle("cookbook-badge--verified", isValidated);
badge.classList.toggle("cookbook-badge--configured", !isValidated);
badge.textContent = isValidated ? "Recorded run" : "Source config";
const heading = document.createElement("strong");
heading.textContent = isValidated ? "Recorded hardware" : "Source configuration";
const details = document.createElement("span");
if (isValidated) {
const recorded = [
hardware.accelerator,
runtime.id === "cuda" ? gpuCountLabel(hardware) : hardware.system_memory,
].filter(Boolean);
const statements = [`${recorded.join(" · ")}.`];
if (hardware.minimum_memory) statements.push(`Documented minimum: ${hardware.minimum_memory}.`);
if (hardware.peak_memory) statements.push(`Measured: ${hardware.peak_memory}.`);
if (!hardware.minimum_memory) statements.push("This recorded device is not a minimum requirement.");
details.textContent = ` ${statements.join(" ")}`;
} else if (runtime.id === "cuda") {
details.textContent = ` NVIDIA CUDA · ${gpuCountLabel(hardware)}. The source does not record the GPU model or VRAM.`;
} else {
details.textContent = ` ${runtime.label}. The source does not record a device or memory requirement.`;
}
container.replaceChildren(heading, details);
if (hardware.evidence_url) {
const evidenceLink = document.createElement("a");
evidenceLink.href = hardware.evidence_url;
evidenceLink.textContent = "View run evidence";
evidenceLink.setAttribute("aria-label", `View recorded hardware evidence for ${recipe.label}`);
container.append(" ", evidenceLink);
}
};
const compactLifecycle = (root) => {
const lifecycle = root.querySelector(".cookbook-lifecycle");
if (!lifecycle || lifecycle.dataset.compact) return;
lifecycle.dataset.compact = "true";
const stages = [...lifecycle.querySelectorAll(".cookbook-lifecycle__stage")];
const active = stages.find((stage) => stage.classList.contains("cookbook-lifecycle__stage--active"));
const planned = stages
.filter((stage) => stage !== active)
.map((stage) => stage.childNodes[0]?.textContent?.trim())
.filter(Boolean);
const summary = document.createElement("span");
summary.className = "cookbook-lifecycle__summary";
summary.textContent = `Next: ${planned.join(", ")}`;
lifecycle.replaceChildren(...(active ? [active] : []), summary);
};
const initFamilyBuilder = async (root) => {
const family = root.dataset.family;
if (!family) return;
compactLifecycle(root);
const modelOptions = root.querySelector("[data-cookbook-model-options]");
const hardwareOptions = root.querySelector("[data-cookbook-hardware-options]");
const description = root.querySelector("[data-cookbook-description]");
const label = root.querySelector("[data-cookbook-label]");
const model = root.querySelector("[data-cookbook-model]");
const task = root.querySelector("[data-cookbook-task]");
const hardwareValue = root.querySelector("[data-cookbook-gpus]");
const artifact = root.querySelector("[data-cookbook-artifact]");
const evidenceCell = root.querySelector("[data-cookbook-evidence]");
const source = root.querySelector("[data-cookbook-source]");
const modelLink = root.querySelector("[data-cookbook-model-link]");
const command = root.querySelector("[data-cookbook-command]");
const status = root.querySelector("[data-cookbook-status]");
const hardwareState = root.querySelector("[data-cookbook-hardware-state]");
const hardwareBadge = root.querySelector("[data-cookbook-hardware-badge]");
const count = root.querySelector("[data-cookbook-count]");
const result = root.querySelector(".cookbook-result");
const commandBlock = root.querySelector(".cookbook-command");
const servingPanel = root.querySelector("[data-cookbook-serving]");
const usage = root.querySelector("[data-cookbook-usage]");
const servingAvailability = root.querySelector("[data-cookbook-serving-availability]");
modelOptions.setAttribute("aria-label", "Recipe");
hardwareOptions.setAttribute("aria-label", "Runtime");
let recipes;
try {
({ recipes } = await loadRecipes(root.dataset.recipes));
} catch (error) {
if (status) status.textContent = "Recipes could not be loaded. Use the maintained examples link below.";
console.error("Failed to load FastVideo cookbook recipes", error);
return;
}
if (!root.isConnected) return;
const familyRecipes = recipes.filter((recipe) => recipe.family === family);
if (!familyRecipes.length) return;
let servingProfiles = {};
let servingLoadFailed = false;
if (servingPanel && familyRecipes.some((recipe) => recipe.serving)) {
try {
const dataUrl = new URL(root.dataset.recipes, document.baseURI);
servingProfiles = await loadRecipes(new URL("cookbook-serving.json", dataUrl));
} catch (error) {
servingLoadFailed = true;
console.error("Failed to load FastVideo serving profiles", error);
}
}
const byId = new Map(familyRecipes.map((recipe) => [recipe.id, recipe]));
const groups = new Map();
familyRecipes.forEach((recipe) => {
const groupId = groupIdFor(recipe);
if (!groups.has(groupId)) groups.set(groupId, []);
groups.get(groupId).push(recipe);
});
if (count) count.textContent = `${familyRecipes.length} maintained recipes`;
modelOptions.replaceChildren();
groups.forEach((groupRecipes, groupId) => {
const representative = groupRecipes[0];
const option = document.createElement("button");
option.type = "button";
option.dataset.recipeGroup = groupId;
option.setAttribute("aria-pressed", "false");
const optionLabel = document.createElement("strong");
optionLabel.textContent = representative.group_label || representative.label;
const optionTask = document.createElement("span");
optionTask.textContent = representative.group_task || representative.task;
option.append(optionLabel, optionTask);
modelOptions.append(option);
});
const query = new URLSearchParams(window.location.search);
const requestedRecipe = query.get("recipe");
const defaultRecipeId = byId.has(root.dataset.defaultRecipe) ? root.dataset.defaultRecipe : familyRecipes[0].id;
let selectedRecipeId = requestedRecipe && byId.has(requestedRecipe) ? requestedRecipe : defaultRecipeId;
let selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
let renderedRuntimeGroup = null;
// Keep previously shared local/openai links working after renaming workflows.
const workflow = (value) => ["local", "python"].includes(value) ? "python" : "server";
let usagePreference = workflow(query.get("use"));
let selectedClient = ["python", "javascript", "curl"].includes(query.get("client")) ? query.get("client") : "curl";
const clientDetails = servingPanel?.querySelector(".cookbook-serving__code");
if (clientDetails && query.has("client")) clientDetails.open = true;
const renderRuntimeOptions = () => {
const groupRecipes = groups.get(selectedGroupId) || [];
const renderedIds = [...hardwareOptions.querySelectorAll("[data-recipe-id]")].map((option) => option.dataset.recipeId);
if (renderedIds.join(",") === groupRecipes.map((recipe) => recipe.id).join(",")) return;
hardwareOptions.replaceChildren();
groupRecipes.forEach((recipe) => {
const runtime = runtimeFor(recipe);
const option = document.createElement("button");
option.type = "button";
option.dataset.recipeId = recipe.id;
option.dataset.runtimeId = runtime.id;
option.setAttribute("aria-pressed", "false");
const optionLabel = document.createElement("strong");
optionLabel.textContent = runtime.label;
const optionHint = document.createElement("span");
optionHint.textContent = runtime.hint;
option.append(optionLabel, optionHint);
hardwareOptions.append(option);
});
};
let notes = root.querySelector("[data-cookbook-notes]");
if (!notes) {
notes = document.createElement("aside");
notes.className = "cookbook-recipe-notes";
notes.dataset.cookbookNotes = "";
notes.hidden = true;
result.insertBefore(notes, commandBlock);
}
const render = ({ groupChanged = false, historyMode = "replace" } = {}) => {
if (!root.isConnected) return;
if (!byId.has(selectedRecipeId)) selectedRecipeId = defaultRecipeId;
let recipe = byId.get(selectedRecipeId);
if (groupChanged || groupIdFor(recipe) !== selectedGroupId) {
const currentRuntime = runtimeFor(recipe).id;
const groupRecipes = groups.get(selectedGroupId) || [];
recipe = groupRecipes.find((candidate) => runtimeFor(candidate).id === currentRuntime) || groupRecipes[0];
selectedRecipeId = recipe.id;
}
selectedGroupId = groupIdFor(recipe);
if (renderedRuntimeGroup !== selectedGroupId) {
renderRuntimeOptions();
renderedRuntimeGroup = selectedGroupId;
}
const runtime = runtimeFor(recipe);
const profile = servingPanel && servingProfiles[recipe.id];
const useServer = Boolean(profile && usagePreference === "server");
// The measured local profile and the server config have separate evidence.
const activeRecipe = useServer ? { ...recipe, hardware: profile.hardware, evidence: "Source-backed" } : recipe;
if (usage) {
usage.querySelectorAll("[data-cookbook-mode]").forEach((option) => {
const selected = option.dataset.cookbookMode === (useServer ? "server" : "python");
option.disabled = option.dataset.cookbookMode === "server" && !profile;
option.classList.toggle("cookbook-option--selected", selected);
option.setAttribute("aria-pressed", String(selected));
});
servingAvailability.textContent = profile
? "The playground and API clients share one server process. Both workflows can run on your own machine."
: servingLoadFailed
? "Server examples could not be loaded. Open the H3 server guide below, or use Python directly."
: "This recipe uses Python directly. For the playground and API clients, choose FastH3 Preview with CUDA or MLX.";
servingPanel.hidden = !useServer;
commandBlock.hidden = useServer;
root.querySelector("[data-cookbook-python-note]").hidden = useServer;
}
if (useServer) {
const isMLX = profile.runtime === "mlx";
servingPanel.querySelector("[data-cookbook-server-lifetime]").textContent = isMLX
? "Start once, then change prompts in the playground or your app. MLX reuses its pipeline and prompt cache, but loads and releases model components between phases to limit unified-memory use. It does not keep all weights resident."
: "Start once, then change prompts in the playground or your app. CUDA requests reuse the loaded model. The Python SDK can also reuse a generator within one process.";
servingPanel.querySelector("[data-cookbook-install-guide]").href = isMLX
? "../../getting_started/installation/mps/#run-fasth3-preview" : "../../getting_started/installation/gpu/";
servingPanel.querySelector("[data-cookbook-prepare]").hidden = !profile.prepare;
servingPanel.querySelector("[data-cookbook-server-prepare]").textContent = profile.prepare;
servingPanel.querySelector("[data-cookbook-server-install]").textContent = profile.install;
servingPanel.querySelector("[data-cookbook-server-command]").textContent = profile.command;
servingPanel.querySelector("[data-cookbook-health-command]").textContent = profile.health_command;
servingPanel.querySelector("[data-cookbook-playground]").href = profile.playground_url;
const client = profile.clients[selectedClient];
const filename = client.source.split("/").pop();
servingPanel.querySelector("[data-cookbook-client-install]").textContent = client.install;
const clientCode = servingPanel.querySelector("[data-cookbook-client-code]");
clientCode.className = `language-${selectedClient === "curl" ? "bash" : selectedClient}`;
clientCode.textContent = client.code;
servingPanel.querySelector("[data-cookbook-client-filename]").textContent = filename;
servingPanel.querySelector("[data-cookbook-client-source]").href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${client.source}`;
const runner = { python: "python", javascript: "node", curl: "bash" }[selectedClient];
servingPanel.querySelector("[data-cookbook-client-run]").textContent = `Save as ${filename} and run ${runner} ${filename}. The MP4 is saved with the job ID as its filename.`;
servingPanel.querySelectorAll("[data-cookbook-client]").forEach((option) => {
option.setAttribute("aria-pressed", String(option.dataset.cookbookClient === selectedClient));
});
}
modelOptions.querySelectorAll("button").forEach((option) => {
const selected = option.dataset.recipeGroup === selectedGroupId;
option.classList.toggle("cookbook-option--selected", selected);
option.setAttribute("aria-pressed", String(selected));
});
hardwareOptions.querySelectorAll("button").forEach((option) => {
const selected = option.dataset.recipeId === recipe.id;
option.classList.toggle("cookbook-option--selected", selected);
option.setAttribute("aria-pressed", String(selected));
const candidate = byId.get(option.dataset.recipeId);
const candidateProfile = servingProfiles[candidate.id];
const candidateRuntime = runtimeFor(candidateProfile && usagePreference === "server"
? { ...candidate, hardware: candidateProfile.hardware } : candidate);
option.querySelector("span").textContent = candidateRuntime.hint;
});
description.textContent = useServer
? `FastH3 Preview generates video with audio. This server profile uses the checked-in ${profile.runtime.toUpperCase()} configuration.`
: recipe.summary;
label.textContent = useServer ? `${recipe.group_label || recipe.label} · Server` : recipe.label;
model.textContent = recipe.model;
task.textContent = recipe.task;
hardwareValue.textContent = runtimeSummary(activeRecipe);
if (artifact) artifact.textContent = useServer ? "MP4 with audio" : recipe.expected_artifact || "Not yet documented for this recipe.";
if (evidenceCell) {
evidenceCell.textContent = activeRecipe.evidence || "Source-backed";
evidenceCell.classList.toggle("cookbook-badge--verified", activeRecipe.evidence === "Verified");
evidenceCell.classList.toggle("cookbook-badge--source-backed", activeRecipe.evidence !== "Verified");
}
source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${useServer ? profile.source : recipe.source}`;
source.textContent = useServer ? "View server configuration" : "Open example source";
modelLink.href = `https://huggingface.co/${recipe.model}`;
command.textContent = recipe.command;
renderHardwareEvidence(hardwareState, hardwareBadge, activeRecipe);
const limitations = useServer ? [
profile.runtime === "mlx"
? "This MLX server config has no recorded hardware run. Measurements from the Python recipe are not server memory requirements. Only text-to-video/audio is wired; reference inputs and fast modes are not exposed here."
: "This server config has no recorded serving benchmark. Compilation is disabled, unlike the measured Python performance profile.",
`${profile.sampling.width} × ${profile.sampling.height} · ${profile.sampling.num_frames} frames · ${profile.sampling.fps} fps. The server supplies these defaults; the client sends the model and prompt.`,
"Generation is serialized. Job metadata is held in memory and is lost when the server restarts.",
] : recipe.limitations || [];
notes.replaceChildren();
notes.hidden = limitations.length === 0;
if (limitations.length) {
const notesHeading = document.createElement("strong");
notesHeading.textContent = "Know before you run";
const notesList = document.createElement("ul");
limitations.forEach((item) => {
const listItem = document.createElement("li");
listItem.textContent = item;
notesList.append(listItem);
});
notes.append(notesHeading, notesList);
}
const nextQuery = new URLSearchParams(window.location.search);
nextQuery.set("recipe", recipe.id);
nextQuery.set("runtime", runtime.id);
nextQuery.delete("gpus");
if (usage) {
nextQuery.set("use", useServer ? "server" : "python");
if (useServer && clientDetails?.open) nextQuery.set("client", selectedClient);
else nextQuery.delete("client");
}
const nextUrl = `${window.location.pathname}?${nextQuery.toString()}${window.location.hash}`;
if (historyMode === "push") window.history.pushState({}, "", nextUrl);
else if (historyMode === "replace") window.history.replaceState({}, "", nextUrl);
const modeSummary = useServer ? " with a persistent server" : "";
status.textContent = `${recipe.label} selected for ${runtime.label}${modeSummary}.`;
};
modelOptions.addEventListener("click", (event) => {
const option = event.target.closest("button[data-recipe-group]");
if (!option) return;
selectedGroupId = option.dataset.recipeGroup;
render({ groupChanged: true, historyMode: "push" });
});
hardwareOptions.addEventListener("click", (event) => {
const option = event.target.closest("button[data-recipe-id]");
if (!option) return;
selectedRecipeId = option.dataset.recipeId;
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
render({ historyMode: "push" });
});
usage?.addEventListener("click", (event) => {
const option = event.target.closest("button[data-cookbook-mode]");
if (!option || option.disabled) return;
usagePreference = option.dataset.cookbookMode;
render({ historyMode: "push" });
});
servingPanel?.addEventListener("click", (event) => {
const option = event.target.closest("button[data-cookbook-client]");
if (!option) return;
selectedClient = option.dataset.cookbookClient;
render({ historyMode: "push" });
});
render();
familyPopstate = () => {
if (!root.isConnected) return;
const nextQuery = new URLSearchParams(window.location.search);
const nextRecipe = nextQuery.get("recipe");
selectedRecipeId = nextRecipe && byId.has(nextRecipe) ? nextRecipe : defaultRecipeId;
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
usagePreference = workflow(nextQuery.get("use"));
selectedClient = ["python", "javascript", "curl"].includes(nextQuery.get("client")) ? nextQuery.get("client") : "curl";
if (clientDetails) clientDetails.open = nextQuery.has("client");
render({ historyMode: "none" });
};
bindFamilyPopstate();
};
const init = () => {
document.querySelectorAll("[data-cookbook]").forEach(async (root) => {
initEvervault(document);
document.querySelectorAll("[data-cookbook][data-family]").forEach((root) => {
if (root.dataset.initialized) return;
root.dataset.initialized = "true";
const select = root.querySelector("[data-cookbook-recipe]");
const model = root.querySelector("[data-cookbook-model]");
const source = root.querySelector("[data-cookbook-source]");
const command = root.querySelector("[data-cookbook-command]");
const status = root.querySelector("[data-cookbook-status]");
try {
const { recipes } = await loadRecipes(root.dataset.recipes);
const byId = new Map(recipes.map((recipe) => [recipe.id, recipe]));
const groups = new Map();
select.replaceChildren();
recipes.forEach((recipe) => {
if (!groups.has(recipe.task)) {
const group = document.createElement("optgroup");
group.label = recipe.task;
groups.set(recipe.task, group);
select.append(group);
}
groups.get(recipe.task).append(new Option(recipe.label, recipe.id));
});
const render = () => {
const recipe = byId.get(select.value);
model.textContent = recipe.model;
source.textContent = recipe.source;
source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${recipe.source}`;
command.textContent = recipe.command;
status.textContent = `${recipe.label} selected.`;
};
select.addEventListener("change", render);
select.disabled = false;
render();
} catch (error) {
status.textContent = "Recipes could not be loaded. Use the examples link below.";
console.error("Failed to load FastVideo cookbook recipes", error);
}
initFamilyBuilder(root);
});
};
+1630 -38
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# Cookbook logo sources
All marks are official publisher assets from Hugging Face organization pages
(vendored byte-for-byte from each org's public avatar). No imitation or
redrawn logos are used. Families without a publisher-appropriate mark use a
plain typographic tile instead — that tile is a UI placeholder, not a logo.
| Asset | Source | Purpose |
| --- | --- | --- |
| `wan-ai.webp` | [Official Wan-AI Hugging Face organization avatar](https://huggingface.co/Wan-AI) | Wan family card and page header |
| `ltx.webp` | [Official Lightricks Hugging Face organization avatar](https://huggingface.co/Lightricks) | LTX family card and page header |
| `tencent-hunyuan.webp` | [Official Tencent Hunyuan Hugging Face organization avatar](https://huggingface.co/Tencent-Hunyuan) | Hunyuan and GameCraft cards, Hunyuan page header |
| `nvidia.webp` | [Official NVIDIA Hugging Face organization avatar](https://huggingface.co/nvidia) | Cosmos and GEN3C cards, Cosmos page header |
| `kandinsky.webp` | [Official Kandinsky Lab Hugging Face organization avatar](https://huggingface.co/kandinskylab) | Kandinsky 5 family card and page header |
| `black-forest-labs.webp` | [Official Black Forest Labs Hugging Face organization avatar](https://huggingface.co/black-forest-labs) | FLUX family card and page header |
| `minimax.webp` | [Official MiniMax Hugging Face organization avatar](https://huggingface.co/MiniMaxAI) | MiniMax H3 family card and page header |
| `tongyi.webp` | [Official Tongyi MAI Hugging Face organization avatar](https://huggingface.co/Tongyi-MAI) | Z-Image family card and page header |
| `zai.webp` | [Official Z.ai Hugging Face organization avatar](https://huggingface.co/zai-org) | GLM-Image family card and page header |
| `stabilityai.webp` | [Official Stability AI Hugging Face organization avatar](https://huggingface.co/stabilityai) | Stable Diffusion and Stable Audio cards and page headers |
| `meituan-longcat.webp` | [Official Meituan LongCat Hugging Face organization avatar](https://huggingface.co/meituan-longcat) | LongCat family card and page header |
| `fastvideo.webp` | [Official FastVideo Hugging Face organization avatar](https://huggingface.co/FastVideo) | Matrix Game and MMAudio cards (converted weights published by this org), DreamX card |
Typographic tiles (no vendored image): TurboDiffusion ("Turbo"), HY-World
("HY"), LingBot ("LB"), MMAudio page header ("MMA"). These publishers have no
single official mark appropriate for reuse in the catalog; add a licensed
asset here if one becomes available.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+41
View File
@@ -6,6 +6,47 @@ Sparse attention mechanism selecting top-k blocks.
VSA is included in the `fastvideo-kernel` package. See the [main Attention page](../index.md) for build instructions.
## Apple Silicon (MiniMax H3 / FastH3)
The native MLX runtime has an inference-only H3 VSA path that is separate from
the CUDA `fastvideo-kernel` package:
- **INT8 / INT6 / INT4** are **weight-only**. They cover linear matrices,
including `attn.to_gate_compress` when you convert with `--include-vsa`.
Attention Q/K/V stay BF16 (or the selected activation dtype). There is no
INT6 Q/K/V attention kernel.
- **Dense-only checkpoints** (the default converter) drop the 50 gate
matrices and keep fused SDPA. They remain valid for dense inference.
- **VSA-capable checkpoints** retain those gates, quantize them on the same
affine grid, and record `vsa.capable` in `mlx_h3_dit.json`. Runtime VSA is
still off until you pass `--vsa`.
- **Tile sizes** 64 `(4, 4, 4)` and 256 `(4, 8, 8)`. Prefix keys can be
`exempt` or `compete`. `--vsa-dense-first-n-steps` and `--vsa-dense-layers`
force dense SDPA on the selected steps or blocks.
- **`--vsa-impl auto`** uses the chunked gather+SDPA **reference** path.
`--vsa-impl simd` runs the SIMD-group Metal kernel (tile 64, head dim 128)
and falls back to reference on unsupported shapes or kernel failure. The
runtime executes a small kernel probe before use and remembers failures
for the process, so later blocks do not retry a broken backend.
It is not the default: 480p four-step generation is faster than reference
but does not yet match reference video. `--vsa-impl reference` is the same
as `auto`.
See the [Apple Silicon guide](../../getting_started/installation/mps.md) for
conversion and `mlx_fasth3.py` flags. Do not enable VSA on a dense-only
checkpoint; reconvert with `--include-vsa` first.
H3 uses fused MLX RMSNorm by default, including dense inference. This can
change BF16 rounding relative to the older explicit normalization path.
`FASTVIDEO_MLX_FAST_NORM` controls Wan normalization only.
The generation report aggregates VSA statistics across all blocks and steps.
`impl_counts` and `fallback_reasons` show mixed execution and fallback.
`video_keep` is the mean number of selected video-key tiles per video query
tile and head, including dense overrides. `achieved_sparsity` is the matching
mean video-tile sparsity; in `compete` mode it measures actual selections,
not the requested top-k budget. These are tile counts, not token-level FLOPs.
## Usage
```python
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Cosmos recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="cosmos" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/nvidia.webp" alt="NVIDIA" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Cosmos inference recipes</h2>
<p>NVIDIA Cosmos Predict 2.5 generates navigable world videos. The maintained example runs the 2B text-to-world checkpoint on a single GPU.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Cosmos recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/nvidia">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- World-generation prompts work best describing a scene and camera motion; the built-in prompt in the example is a known-good starting point.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+129
View File
@@ -0,0 +1,129 @@
---
hide:
- toc
---
# FLUX recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="flux" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/black-forest-labs.webp" alt="Black Forest Labs" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>FLUX inference recipes</h2>
<p>Black Forest Labs' FLUX family covers FLUX.1 dev and FLUX.2 (dev and distilled Klein) text-to-image. FLUX.1 registers no `model_family` in the registry and is grouped under FLUX for documentation only.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading FLUX recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/black-forest-labs">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- FLUX.1 dev defaults to a local `official_weights/FLUX.1-dev` directory in the example; the cookbook command passes the Hugging Face ID explicitly instead.
- Image outputs land under `outputs/`; adjust `--output` if that path is not writable.
- Gated checkpoints (FLUX.1 dev): run `huggingface-cli login` and accept the license on Hugging Face first.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# GLM-Image recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="glm_image" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/zai.webp" alt="Z.ai" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>GLM-Image inference recipes</h2>
<p>GLM-Image from Z.ai supports both text-to-image generation and instruction-based image editing, each with a maintained example.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading GLM-Image recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/zai-org">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- The editing example reads `assets/images/couple.jpg` from the repository root, so run it from a repo checkout rather than an arbitrary working directory.
- Output paths default under `image_output/`; pass `--output` to change them.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+129
View File
@@ -0,0 +1,129 @@
---
hide:
- toc
---
# Hunyuan recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="hunyuan" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/tencent-hunyuan.webp" alt="Tencent Hunyuan" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Hunyuan inference recipes</h2>
<p>HunyuanVideo 1.5 is Tencent's video generation family. The maintained examples cover 480p text-to-video with CPU offload and a full 480p-to-1080p upscale chain.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Hunyuan recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/Tencent-Hunyuan">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Out of memory: `basic_hy15.py` already enables dit/VAE/text-encoder CPU offload; further tradeoffs are described in [Optimizations](../inference/optimizations.md).
- `pin_cpu_memory` errors on low-RAM machines are documented inline in the example; set it to false as the source comment suggests.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+466 -37
View File
@@ -1,44 +1,473 @@
---
hide:
- toc
---
# Inference Cookbook
Choose a complete recipe maintained in the FastVideo repository. Each command
runs its checked-in source directly, so coupled model, GPU, offload, and
attention settings do not drift into unsupported combinations.
<div class="cookbook-shell cookbook-catalog" data-cookbook-gallery>
<header class="cookbook-hero">
<p class="cookbook-eyebrow">FastVideo inference cookbook</p>
<h2>Choose by output, then by family.</h2>
<p class="cookbook-hero__lede">
Open a family to pick a maintained recipe and a runtime FastVideo
actually supports. Every command runs a checked-in source, so the model,
platform, offload, and attention settings stay tied to that example.
Cards below group video, image, audio, and interactive world models.
Mode chips on each card are the workloads FastVideo maintains for that
family, not a promise that every flag works on every runtime.
</p>
<a class="cookbook-inline-link" href="../inference/support_matrix/">
View the full support matrix <span aria-hidden="true">→</span>
</a>
<a class="cookbook-inline-link" href="./openai-api/">Run FastH3 with a playground and API <span aria-hidden="true">→</span></a>
</header>
The commands expect a local clone:
<section class="cookbook-section" id="video-models" aria-labelledby="video-models-heading">
<div class="cookbook-section__heading">
<h2 id="video-models-heading">Video</h2>
</div>
<div class="cookbook-family-grid">
<a class="cookbook-family-tile cookbook-family-tile--ready cookbook-family-tile--featured" href="./minimax-h3/" aria-label="Open MiniMax H3 recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/minimax.webp" alt="" width="132" height="132" loading="eager">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>MiniMax H3</strong><small>Video + stereo audio</small></span>
<span class="cookbook-count">6 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2VA</li>
<li>FL2VA</li>
<li>Ref2VA</li>
<li>MLX T2VA</li>
</ul>
</span>
</a>
```bash
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
```
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./wan/" aria-label="Open Wan recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/wan-ai.webp" alt="" width="132" height="132" loading="eager">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Wan</strong><small>FastWan CUDA and FastMetal MLX</small></span>
<span class="cookbook-count">7 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
<li>I2V</li>
<li>TI2V</li>
<li>MLX T2V</li>
</ul>
</span>
</a>
<div class="cookbook-picker" data-cookbook data-recipes="../assets/cookbook-recipes.json">
<label for="cookbook-recipe"><strong>Recipe</strong></label>
<select id="cookbook-recipe" data-cookbook-recipe disabled>
<option>Loading recipes…</option>
</select>
<dl>
<dt>Model</dt>
<dd data-cookbook-model>Loading…</dd>
<dt>Source</dt>
<dd><a data-cookbook-source href="../inference/examples/basic/">Browse maintained examples</a></dd>
</dl>
<pre><code class="language-bash" data-cookbook-command>Loading…</code></pre>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
<noscript>
JavaScript is needed for the recipe picker. Browse the
<a href="../inference/examples/examples_inference_index/">inference examples</a>
instead.
</noscript>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./ltx/" aria-label="Open LTX recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/ltx.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>LTX</strong><small>Video with synchronized audio</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./hunyuan/" aria-label="Open Hunyuan recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/tencent-hunyuan.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Hunyuan</strong><small>480p and 1080p upscale</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./kandinsky5/" aria-label="Open Kandinsky 5 recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/kandinsky.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Kandinsky 5</strong><small>Text and image to video</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
<li>I2V</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./longcat/" aria-label="Open LongCat recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/meituan-longcat.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>LongCat</strong><small>T2V, I2V, optional refine</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
<li>I2V</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./turbodiffusion/" aria-label="Open TurboDiffusion recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<span class="cookbook-family-tile__monogram" aria-hidden="true">Turbo</span>
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>TurboDiffusion</strong><small>Accelerated Wan profiles</small></span>
<span class="cookbook-count">3 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2V</li>
<li>I2V</li>
</ul>
</span>
</a>
</div>
</section>
<section class="cookbook-section" id="image-models" aria-labelledby="image-models-heading">
<div class="cookbook-section__heading">
<h2 id="image-models-heading">Image</h2>
</div>
<div class="cookbook-family-grid">
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./flux/" aria-label="Open FLUX recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/black-forest-labs.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>FLUX</strong><small>FLUX.1 and FLUX.2</small></span>
<span class="cookbook-count">3 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2I</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./glm-image/" aria-label="Open GLM-Image recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/zai.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>GLM-Image</strong><small>Generate and edit</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2I</li>
<li>Edit</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./z-image/" aria-label="Open Z-Image recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/tongyi.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Z-Image</strong><small>Turbo text to image</small></span>
<span class="cookbook-count">1 recipe</span>
</span>
<ul class="cookbook-mode-row">
<li>T2I</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./stable-diffusion/" aria-label="Open Stable Diffusion recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/stabilityai.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Stable Diffusion</strong><small>SD 3.5 Medium</small></span>
<span class="cookbook-count">1 recipe</span>
</span>
<ul class="cookbook-mode-row">
<li>T2I</li>
</ul>
</span>
</a>
</div>
</section>
<section class="cookbook-section" id="audio-models" aria-labelledby="audio-models-heading">
<div class="cookbook-section__heading">
<h2 id="audio-models-heading">Audio</h2>
</div>
<div class="cookbook-family-grid">
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./stable-audio/" aria-label="Open Stable Audio recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/stabilityai.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Stable Audio</strong><small>Open 1.0 and Small</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>T2A</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./mmaudio/" aria-label="Open MMAudio recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/fastvideo.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>MMAudio</strong><small>Video or text to audio</small></span>
<span class="cookbook-count">1 recipe</span>
</span>
<ul class="cookbook-mode-row">
<li>V2A</li>
<li>T2A</li>
</ul>
</span>
</a>
</div>
</section>
<section class="cookbook-section" id="world-models" aria-labelledby="world-models-heading">
<div class="cookbook-section__heading">
<h2 id="world-models-heading">World and interactive</h2>
</div>
<div class="cookbook-family-grid">
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./cosmos/" aria-label="Open Cosmos recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/nvidia.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Cosmos</strong><small>Text to world video</small></span>
<span class="cookbook-count">1 recipe</span>
</span>
<ul class="cookbook-mode-row">
<li>T2W</li>
</ul>
</span>
</a>
<a class="cookbook-family-tile cookbook-family-tile--ready" href="./matrix-game/" aria-label="Open Matrix Game recipes">
<span class="cookbook-family-tile__visual" data-evervault>
<span class="cookbook-evervault" aria-hidden="true">
<span class="cookbook-evervault__gradient"></span>
<span class="cookbook-evervault__noise" data-cookbook-pattern></span>
</span>
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/fastvideo.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>Matrix Game</strong><small>Image-conditioned worlds</small></span>
<span class="cookbook-count">2 recipes</span>
</span>
<ul class="cookbook-mode-row">
<li>I2W</li>
</ul>
</span>
</a>
</div>
</section>
<section class="cookbook-section" id="planned-families" aria-labelledby="planned-families-heading">
<div class="cookbook-section__heading">
<h2 id="planned-families-heading">Pages still to write</h2>
<p>These families already have runnable examples. The cookbook page is not ready, so the cards are not links.</p>
</div>
<div class="cookbook-family-grid">
<article class="cookbook-family-tile cookbook-family-tile--coming" aria-label="GameCraft cookbook page planned; runnable examples exist">
<span class="cookbook-family-tile__visual">
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/tencent-hunyuan.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>GameCraft</strong><small>Game world generation</small></span>
<span class="cookbook-count">Page planned</span>
</span>
</span>
</article>
<article class="cookbook-family-tile cookbook-family-tile--coming" aria-label="GEN3C cookbook page planned; runnable examples exist">
<span class="cookbook-family-tile__visual">
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/nvidia.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>GEN3C</strong><small>Novel-view video</small></span>
<span class="cookbook-count">Page planned</span>
</span>
</span>
</article>
<article class="cookbook-family-tile cookbook-family-tile--coming" aria-label="HY-World cookbook page planned; runnable examples exist">
<span class="cookbook-family-tile__visual">
<span class="cookbook-family-tile__logo-wrap">
<span class="cookbook-family-tile__monogram" aria-hidden="true">HY</span>
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>HY-World</strong><small>Interactive world play</small></span>
<span class="cookbook-count">Page planned</span>
</span>
</span>
</article>
<article class="cookbook-family-tile cookbook-family-tile--coming" aria-label="DreamX cookbook page planned; runnable examples exist">
<span class="cookbook-family-tile__visual">
<span class="cookbook-family-tile__logo-wrap">
<img class="off-glb" src="../assets/logos/fastvideo.webp" alt="" width="132" height="132" loading="lazy">
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>DreamX</strong><small>World generation</small></span>
<span class="cookbook-count">Page planned</span>
</span>
</span>
</article>
<article class="cookbook-family-tile cookbook-family-tile--coming" aria-label="LingBot cookbook page planned; runnable examples exist">
<span class="cookbook-family-tile__visual">
<span class="cookbook-family-tile__logo-wrap">
<span class="cookbook-family-tile__monogram" aria-hidden="true">LB</span>
</span>
</span>
<span class="cookbook-family-tile__footer">
<span class="cookbook-family-tile__footer-top">
<span><strong>LingBot</strong><small>Video and world models</small></span>
<span class="cookbook-count">Page planned</span>
</span>
</span>
</article>
</div>
</section>
<section class="cookbook-roadmap" aria-labelledby="roadmap-heading">
<h2 id="roadmap-heading">Inference first, then the full workflow</h2>
<p>
Inference is the first complete stage. Distillation, fine-tuning,
training, evaluation, optimization, and deployment will reuse the same
family-first structure as their recipes land. Each family page shows
which stages are available and which are planned.
</p>
</section>
</div>
## Customize a recipe
Start from the checked-in source, then change only the settings your model
supports:
- [Configuration](../inference/configuration.md) covers the Python and CLI
config surfaces.
- [Optimizations](../inference/optimizations.md) covers attention backends,
compilation, and memory tradeoffs.
- [Support matrix](../inference/support_matrix.md) lists supported models and
optimizations.
<small class="cookbook-logo-credit">
Catalog marks come from the official model publishers' Hugging Face
organizations; typographic tiles are placeholders, never invented logos. See
<a href="https://github.com/hao-ai-lab/FastVideo/blob/main/docs/assets/logos/SOURCES.md">docs/assets/logos/SOURCES.md</a>.
</small>
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Kandinsky 5 recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="kandinsky5" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/kandinsky.webp" alt="Kandinsky Lab" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Kandinsky 5 inference recipes</h2>
<p>Kandinsky 5.0 from the Kandinsky Lab covers text-to-video and image-to-video in Lite and Pro variants, including distilled checkpoints for faster sampling.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Kandinsky 5 recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/kandinskylab">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Alternative Lite/Pro checkpoints are listed inline in the maintained examples; swap the model string only after checking its Hugging Face card.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
Both recipes map to checked-in FastVideo examples and recorded single-GPU B200 runs. The image-to-video run also records 10,365.89 MB peak GPU memory. These measurements describe the recorded runs; they are not minimum hardware requirements.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# LongCat recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="longcat" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/meituan-longcat.webp" alt="Meituan LongCat" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>LongCat inference recipes</h2>
<p>LongCat Video from Meituan covers text-to-video and image-to-video, and its maintained examples chain optional distilled and 720p refinement passes.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading LongCat recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/meituan-longcat">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Each LongCat script runs multiple passes (basic, distilled, refine); total runtime scales accordingly, and every pass prints its own output directory.
- Out of memory: the sources already enable VAE and text-encoder CPU offload; further options are covered in [Offloading](../inference/offloading.md).
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+129
View File
@@ -0,0 +1,129 @@
---
hide:
- toc
---
# LTX recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="ltx2" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/ltx.webp" alt="Lightricks" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>LTX inference recipes</h2>
<p>LTX-2 from Lightricks generates video with synchronized audio. FastVideo maintains both a distilled four-GPU path and a base 1088p single-GPU path.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading LTX recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/Lightricks">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- The distilled recipe is source-configured for four GPUs; running it on fewer GPUs is unverified and may fail during distributed setup.
- Audio-less output usually means the base checkpoint resolved instead of the distilled LTX-2 checkpoint with audio; check the loaded model ID in the logs.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Matrix Game recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="matrixgame" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/fastvideo.webp" alt="FastVideo org (converted weights)" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Matrix Game inference recipes</h2>
<p>Matrix Game generates playable interactive worlds from an input image and prompt. Maintained examples cover Matrix Game 2.0 variants and Matrix Game 3.0 at 720p.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Matrix Game recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/FastVideo">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Matrix Game 3.0 downloads its reference input image from GitHub; offline machines should pre-download it and edit the `IMAGE_URL` constant locally.
- Streaming variants of Matrix Game 2.0 exist under `examples/inference/basic/basic_matrixgame2_streaming.py` but are not included as cookbook recipes.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+252
View File
@@ -0,0 +1,252 @@
---
hide:
- toc
---
# MiniMax H3 recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="minimax_h3" data-default-recipe="fasth3-preview-cuda" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/minimax.webp" alt="MiniMax" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Primary focus · Inference</p>
<h2>MiniMax H3 recipes</h2>
<p>Generate video and audio with H3. Run a server on CUDA or Apple Silicon MLX to iterate on prompts, or call the pipeline directly from Python.</p>
<span class="cookbook-count" data-cookbook-count>6 maintained recipes</span>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<details class="cookbook-modes">
<summary>Compare H3 modes and options</summary>
<h2 id="h3-modes-heading">Supported modes</h2>
<p>
CUDA covers T2VA, FL2VA, and Ref2VA on the full checkpoint, plus FastH3
Preview and FastH3 LoRA. MLX is T2VA only. Temporal <code>--fast</code>,
spatial <code>--fast-spatial</code>, and opt-in VSA are flags on the same
MLX script, not extra recipes.
</p>
<div class="cookbook-modes__table-wrap">
<table>
<thead>
<tr>
<th>Mode</th>
<th>CUDA</th>
<th>MLX FastH3</th>
</tr>
</thead>
<tbody>
<tr>
<td>T2VA</td>
<td>Full H3, FastH3 Preview, FastH3 LoRA</td>
<td>FastH3 Preview after a local DiT conversion</td>
</tr>
<tr>
<td>FL2VA</td>
<td>Full H3</td>
<td>Not wired</td>
</tr>
<tr>
<td>Ref2VA</td>
<td>Full H3</td>
<td>Not wired</td>
</tr>
<tr>
<td>Temporal <code>--fast</code></td>
<td>No cookbook recipe</td>
<td>Shorter video denoise, MLX RIFE back to <code>--num-frames</code>, full-duration audio</td>
</tr>
<tr>
<td>VSA</td>
<td>Trained sparse attention on FastH3 CUDA</td>
<td>Opt-in. Convert with <code>--include-vsa</code> into a new directory such as <code>./FastH3-MLX-vsa</code>, then pass <code>--vsa</code>. Do not overwrite an existing dense export.</td>
</tr>
<tr>
<td>Spatial <code>--fast-spatial</code></td>
<td>No cookbook recipe</td>
<td>Denoise and decode at height/width divided by <code>--fast-spatial-scale</code>, then resample. Composes with <code>--fast</code>.</td>
</tr>
<tr>
<td>Two-pass refine</td>
<td>No cookbook recipe</td>
<td>Not wired</td>
</tr>
</tbody>
</table>
</div>
</details>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick an H3 recipe and runtime</h2>
<p>Choose the result you want, then use a maintained CUDA or MLX path.
Device claims stay tied to checked-in sources and recorded runs.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading MiniMax H3 recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<div class="cookbook-selection-row" data-cookbook-usage>
<div class="cookbook-selection-row__label">
<strong>Workflow</strong>
<span>Both can run locally</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" role="group" aria-label="How to run this recipe">
<button type="button" data-cookbook-mode="server" aria-pressed="false"><strong>Run a server</strong><span>Playground, cURL, or an API client</span></button>
<button type="button" data-cookbook-mode="python" aria-pressed="false"><strong>Use Python directly</strong><span>Call the model in your own process</span></button>
</div>
</div>
<p class="cookbook-hardware-note" data-cookbook-serving-availability></p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Hardware</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre id="cookbook-local-command"><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<p class="cookbook-hardware-note" data-cookbook-python-note>Running this script again starts a new process and reloads the model. To iterate in Python, create the generator once and reuse it for multiple prompts.</p>
<div class="cookbook-serving" data-cookbook-serving hidden>
<p class="cookbook-serving__intro" data-cookbook-server-lifetime>Start once, then change prompts in the playground or your app. You can run the server and clients on the same machine.</p>
<section class="cookbook-serving__step" aria-labelledby="serving-install-heading">
<h4 id="serving-install-heading"><span aria-hidden="true">1</span> Prepare the machine</h4>
<p>Run from your FastVideo clone in an activated Python environment. See <a data-cookbook-install-guide href="../../getting_started/installation/gpu/">installation requirements</a>.</p>
<div class="cookbook-command"><div class="cookbook-command__bar"><span>GPU machine · Terminal</span></div><pre id="cookbook-server-install"><code class="language-bash" data-cookbook-server-install></code></pre></div>
<details class="cookbook-serving__prepare" data-cookbook-prepare hidden><summary>Download and convert MLX weights once</summary><p>Skip this if the weights are already prepared. Edit the paths in <code>examples/serving/mlx_fasth3.yaml</code> to use your existing files. Install <code>ffmpeg</code> for video and audio output.</p><div class="cookbook-command"><pre id="cookbook-server-prepare"><code class="language-bash" data-cookbook-server-prepare></code></pre></div></details>
</section>
<section class="cookbook-serving__step" aria-labelledby="serving-start-heading">
<h4 id="serving-start-heading"><span aria-hidden="true">2</span> Start the server</h4>
<p>Keep this terminal running while you use the playground or API clients.</p>
<div class="cookbook-command"><div class="cookbook-command__bar"><span>GPU machine · Terminal</span></div><pre id="cookbook-server-command"><code class="language-bash" data-cookbook-server-command></code></pre></div>
<details class="cookbook-serving__check"><summary>Check that the server is ready</summary><p>In another terminal, this returns <code>{"status":"ok"}</code> after startup.</p><div class="cookbook-command"><pre id="cookbook-health-command"><code class="language-bash" data-cookbook-health-command></code></pre></div></details>
</section>
<section class="cookbook-serving__step" aria-labelledby="serving-client-heading">
<h4 id="serving-client-heading"><span aria-hidden="true">3</span> Generate and download a video</h4>
<div class="cookbook-serving__playground">
<div><strong>Try prompts in your browser</strong><p>Edit a prompt, generate, and watch the result. The playground uses the same server as cURL and your app.</p></div>
<a class="cookbook-serving__launch" data-cookbook-playground href="http://127.0.0.1:8000/playground/" target="_blank" rel="noopener">Open playground <span aria-hidden="true">↗</span></a>
</div>
<p class="cookbook-serving__local-hint">Open after the server is ready. On a remote GPU machine, <a href="../openai-api/#connect-your-app">forward port 8000</a> to your computer first. This opens a local page, not a hosted demo.</p>
<details class="cookbook-serving__code"><summary>Use cURL or an SDK</summary>
<p>Each example submits a job, checks its status, and saves the MP4. The Python and JavaScript examples use OpenAI-compatible clients; no OpenAI account is needed.</p>
<div class="cookbook-serving__clients" role="group" aria-label="API client language">
<button type="button" data-cookbook-client="curl" aria-pressed="false">cURL</button>
<button type="button" data-cookbook-client="python" aria-pressed="true">Python</button>
<button type="button" data-cookbook-client="javascript" aria-pressed="false">JavaScript</button>
</div>
<div class="cookbook-command"><div class="cookbook-command__bar"><span>Client dependencies</span></div><pre id="cookbook-client-install"><code class="language-bash" data-cookbook-client-install></code></pre></div>
<div class="cookbook-command cookbook-command--client"><div class="cookbook-command__bar"><span data-cookbook-client-filename>video.py</span><a data-cookbook-client-source href="https://github.com/hao-ai-lab/FastVideo/tree/main/examples/serving/clients">View source</a></div><pre id="cookbook-client-code"><code data-cookbook-client-code></code></pre></div>
<p data-cookbook-client-run></p>
</details>
</section>
<p class="cookbook-serving__boundary">This is a local development server without built-in API-key authentication. The client key <code>local</code> is a placeholder. Keep the server on loopback; use an authenticated TLS proxy before exposing it publicly. Run the JavaScript client in your webapp's backend, not in a browser with a private key.</p>
<a href="../openai-api/">Server guide and API compatibility →</a>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/MiniMaxAI">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../openai-api/">H3 server guide</a> or the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
CUDA FastH3 uses the pinned performance dependencies:
UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"
Apple Silicon uses the native MLX extra and a locally converted H3 DiT:
uv pip install -e ".[mlx]"
Follow the [Apple Silicon guide](../getting_started/installation/mps.md#run-fasth3-preview)
for the download, conversion, and storage requirements.
## Troubleshooting
- The full CUDA H3 examples request four GPUs by default. Their sources do not claim a GPU model or memory minimum.
- The FastH3 CUDA performance profile was measured on four GB200 GPUs. Use its strict profile when exact operation order matters more than the measured performance configuration.
- The MLX source runtime supports T2VA, optional temporal `--fast`, optional spatial `--fast-spatial`, and opt-in VSA on `--include-vsa` checkpoints. FL2VA, Ref2VA, and two-pass refinement are not wired.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
Every command, model ID, and flag on this page maps to a checked-in FastVideo source. Recipes marked **Verified** also have a recorded hardware path in linked FastVideo evidence. The full H3 CUDA examples remain **Source-backed** where the source records a GPU count but no GPU model or memory requirement. Unlisted hardware is unknown, not unsupported.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# MMAudio recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="mmaudio" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<span class="cookbook-family-tile__monogram" aria-hidden="true">MMA</span>
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>MMAudio inference recipes</h2>
<p>MMAudio adds synchronized audio to video or generates audio from text. The checkpoint must be in Diffusers layout; the recipe loads the converted FastVideo repo through the `MMAUDIO_MODEL_PATH` environment variable the example reads.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading MMAudio recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/FastVideo">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- If the example reports a missing local `converted_weights/mmaudio/large_44k_v2` path, the `MMAUDIO_MODEL_PATH` env var from the cookbook command was not applied; export it in the same shell.
- Alternatively convert upstream weights yourself with `scripts/checkpoint_conversion/convert_mmaudio_to_diffusers.py` and point the env var at the result.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+224
View File
@@ -0,0 +1,224 @@
# Run H3 with a server and playground
Start FastH3 on CUDA or Apple Silicon MLX, then iterate on prompts in a browser,
with cURL, or from your app. The server and clients can run on the same machine.
The server supports the OpenAI-compatible video-job API. The Python and
JavaScript client examples use that interface, but requests go to FastVideo.
You do not need an OpenAI account or cloud key.
The [H3 recipe selector](minimax-h3.md) provides the same workflow with runtime
selection. This guide covers FastH3 Preview text-to-video/audio. Other H3
recipes keep their direct Python commands.
CUDA requests reuse one loaded `VideoGenerator`. The Python SDK can do the same
when you reuse the generator across `generate()` calls. MLX keeps one
`MiniMaxH3MLXPipeline` and a prompt-embedding cache, but still loads and releases
components between phases to fit unified memory. MLX serving does not keep all
weights resident or remove phase-loading time. Use a server when you want
separate clients to share the process without restarting scripts.
## Install and start the server
### CUDA
Use a FastVideo clone and an activated Python environment. Complete the
[CUDA installation requirements](../getting_started/installation/gpu.md)
before running these commands:
```bash
UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"
fastvideo serve --config examples/serving/openai_fasth3.yaml --server.host 127.0.0.1
```
The configuration loads `FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2` and
advertises it as `fasth3`. It configures four CUDA GPUs but does not record
a GPU model or VRAM requirement. This is a source-backed server profile, not
the measured GB200 Python performance profile. Compilation is disabled.
Keep the server running. In another terminal, check readiness:
```bash
curl --fail-with-body http://127.0.0.1:8000/health
```
After model loading completes, the response is `{"status":"ok"}`.
### Apple Silicon MLX
Complete the [Apple Silicon installation](../getting_started/installation/mps.md#run-fasth3-preview),
including `ffmpeg`. From your FastVideo clone, install the MLX extra:
```bash
uv pip install -e ".[mlx]"
```
Download and convert the weights once. Skip this step if you already have the
snapshot and converted DiT:
```bash
hf download FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2 --local-dir ./FastH3-Preview-v0.2
python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py --model-root ./FastH3-Preview-v0.2/transformer --out ./FastH3-MLX --formats "int6"
```
Edit `generator.model_root` and `generator.mlx_checkpoint` in
`examples/serving/mlx_fasth3.yaml` if your weights are elsewhere. Paths are
relative to the directory where you launch the server. Start it with:
```bash
python -m fastvideo.entrypoints.openai.mlx_server --config examples/serving/mlx_fasth3.yaml
```
This uses the native MLX pipeline, not PyTorch MPS. The default output is
832 × 480, 124 frames at 24 fps, with four DiT forwards and the full H3 VAE.
The HTTP field `num_inference_steps=5` describes five sigma points; the adapter
passes `num_steps=4` to MLX. Temporal/spatial fast modes, VSA, reference inputs,
LoRA selection, and alternate decoders are not exposed by this server adapter.
Unsupported request options return HTTP 400 before a job starts.
The server binds to `127.0.0.1:8000` and advertises `fasth3`, so the playground
and clients below work unchanged. Do not run CUDA and MLX servers on the same
port. MLX readiness means the pipeline is initialized; components load during
generation. The first request can take longer than a repeated cached prompt.
The MLX server has no recorded device or unified-memory requirement. The
direct Python recipe's M4 Max measurements are not a server benchmark or a
minimum-memory claim.
## Open the playground
Open [the local H3 playground](http://127.0.0.1:8000/playground/) after startup.
Write a prompt, optionally set a seed, then select **Generate video**. The page
checks the job status and shows the completed video with a download link. Edit
the prompt and generate again without restarting the server.
The playground sends requests to `/v1/videos` on the same server that serves
the page. **Use this prompt with cURL** shows the equivalent submission. Recent
jobs include requests from other clients, so a script and the playground can
use the same model process. Opening the page or copying a command does not
start generation.
The page URL includes the active job ID. Reloading that URL resumes status
checks without resubmitting the prompt. A failed connection or a 30-minute
polling timeout does not cancel execution. Select **Check status** to reconnect.
If submission itself is interrupted, check Recent jobs before submitting again.
This first playground supports H3 text-to-video/audio. Reference-media inputs
remain available through the API, not through the playground. It does not start
or manage a GPU server for you.
## Generate with cURL or an SDK
These examples use the server's resolution, frame count, and sampling defaults.
Do not copy Sora-specific durations or resolutions onto H3. Both server configs
use 124 frames, 24 fps, and the five-point distilled sigma schedule with four
DiT forwards. CUDA uses 1344 × 768; MLX uses 832 × 480.
Each client submits a job, checks for completion or failure, and downloads an
MP4 named after the job ID. Polling stops after 30 minutes; a timeout does not
cancel GPU execution. Keep the printed job ID to retrieve its status later.
Transport retries are disabled to avoid accidental duplicate submissions.
### OpenAI Python
Install the tested client, then run the checked-in example:
```bash
python -m pip install openai==3.6.0
python examples/serving/clients/video.py
```
```python
--8<-- "examples/serving/clients/video.py"
```
### OpenAI JavaScript
Use Node.js 22 or later on your computer or in your webapp's backend. Do not put a
private server key in browser code.
```bash
npm ci --prefix examples/serving/clients
node examples/serving/clients/video.mjs
```
```javascript
--8<-- "examples/serving/clients/video.mjs"
```
### cURL
Install `curl` and `jq`, then run:
```bash
bash examples/serving/clients/video.sh
```
```bash
--8<-- "examples/serving/clients/video.sh"
```
## Connect your app
Set `FASTVIDEO_BASE_URL` to the FastVideo endpoint, including `/v1`, and
`FASTVIDEO_MODEL` to its advertised model alias. The examples default to
`http://127.0.0.1:8000/v1` and `fasth3`.
For a remote GPU machine, keep the server bound to loopback and forward the
port. Replace `user@gpu-host` with your SSH destination:
```bash
ssh -N -L 8000:127.0.0.1:8000 user@gpu-host
```
Then open `http://127.0.0.1:8000/playground/` on your computer. The same forwarded
address works for the cURL and SDK clients. If local port 8000 is occupied, use
`-L 8001:127.0.0.1:8000`, open port 8001, and set `FASTVIDEO_BASE_URL` to
`http://127.0.0.1:8001/v1` for the example clients.
Your webapp backend can submit a job and return its ID to the browser. Poll
from the backend, then proxy the completed download or store it in your own
artifact store. Do not hold a browser request open for the entire generation.
The client key `local` is a placeholder required by the SDK, not server
authentication. FastVideo's HTTP server has no built-in API-key check. Before
public deployment, put it behind an authenticated TLS proxy with restricted
origins, request limits, and access controls. If your proxy uses bearer tokens,
set `FASTVIDEO_API_KEY` on your backend. Never reuse an OpenAI cloud key here.
## Compatibility and limits
The client examples target the OpenAI video-job API, not Chat Completions.
The compatibility tests cover real HTTP requests with the pinned SDKs and a
fake generator. They establish client and protocol behavior, not GPU generation
quality, latency, or memory use. The server configuration still needs a recorded
H3 hardware run before it can be marked Verified in the cookbook.
| Operation | Endpoint |
| --- | --- |
| List served models | `GET /v1/models` |
| Create a video job | `POST /v1/videos` |
| Retrieve status, including a failed job | `GET /v1/videos/{id}` |
| List jobs | `GET /v1/videos` |
| Download the MP4 | `GET /v1/videos/{id}/content` |
| Delete a job and its artifact | `DELETE /v1/videos/{id}` |
Failed jobs return HTTP 200 with `status: "failed"` and an `error` object so
SDK polling can stop. Invalid requests return HTTP 400; missing jobs return
HTTP 404. The download endpoint supports only the `video` variant, not
thumbnails or spritesheets. Remix, extensions, characters, and an OpenAI Files
store are not implemented. `/v1/videos/sync` is a FastVideo extension and is
not used by these client examples.
OpenAI marks its hosted Sora API as deprecated. FastVideo runs its own models;
the [OpenAI video API reference](https://developers.openai.com/api/reference/python/resources/videos/methods/create)
describes the client interface, not FastVideo model availability. The examples
pin tested SDK versions because future SDKs may change or remove video helpers.
The server serializes generation through one loaded pipeline. Job metadata is
in memory and is lost on restart. Async artifacts remain on disk until deleted
through the API; establish a retention policy for production use. Deleting an
in-progress job does not interrupt an already running CUDA call.
For model-specific request fields and reference media, see the
[HTTP contract](../design/server_contracts/openai.md).
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Stable Audio recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="stable_audio" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/stabilityai.webp" alt="Stability AI" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Stable Audio inference recipes</h2>
<p>Stable Audio Open generates audio from text. FastVideo requires the converted Diffusers-format repos published by the FastVideo organization; upstream monolithic checkpoints are not loader-compatible.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Stable Audio recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/stabilityai">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Loader errors about monolithic checkpoints mean an upstream `stabilityai/stable-audio-open-*` ID was used; use the FastVideo converted repos from the recipes.
- Duration and step knobs (`audio_end_in_s`, `num_inference_steps`) are documented inline in the example source.
## Evidence status
The Stable Audio Open 1.0 recipe maps to a checked-in example and a recorded single-GPU B200 run. The Stable Audio Open Small recipe remains **Source-backed** because the implementation PR did not record a full run for that gated checkpoint. Neither recipe claims a minimum VRAM requirement.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Stable Diffusion recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="sd35" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/stabilityai.webp" alt="Stability AI" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Stable Diffusion inference recipes</h2>
<p>Stable Diffusion 3.5 Medium is Stability AI's text-to-image model in this catalog. The maintained example sweeps a small prompt set across seeds.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Stable Diffusion recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/stabilityai">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- The example writes several PNGs under `outputs/sd35/samples/`; make sure the output directory is writable.
- Gated checkpoints: Stability AI models require accepting the license and `huggingface-cli login`.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# TurboDiffusion recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="turbodiffusion" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<span class="cookbook-family-tile__monogram" aria-hidden="true">Turbo</span>
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>TurboDiffusion inference recipes</h2>
<p>TurboDiffusion profiles accelerate Wan checkpoints with step-distilled sampling and the SLA attention backend. These recipes follow the registry's `turbodiffusion` model family.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading TurboDiffusion recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/loayrashid">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- TurboDiffusion paths load community-published `loayrashid/TurboWan*` checkpoints; availability is governed by those repos.
- The SLA attention backend used by the I2V recipe is selected inside the example source; do not combine it with another `FASTVIDEO_ATTENTION_BACKEND` override in the same shell.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
+188
View File
@@ -0,0 +1,188 @@
---
hide:
- toc
---
# Wan recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="wan" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/wan-ai.webp" alt="Wan-AI" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Wan inference recipes</h2>
<p>CUDA covers FastWan and Wan2.1/2.2 text and image recipes. Apple Silicon uses the released FastMetal 1.3B, 5B, and 14B MLX T2V paths. The speed flags below are switches on those same scripts, not extra recipes.</p>
<span class="cookbook-count" data-cookbook-count>7 maintained recipes</span>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-modes" aria-labelledby="wan-modes-heading">
<h2 id="wan-modes-heading">Supported modes</h2>
<p>
FastMetal MLX is T2V in the checked-in examples. Image-to-video and
TI2V stay on the CUDA recipes. Temporal <code>--fast</code> composes
with either spatial path. <code>--refine</code> and
<code>--fast-spatial</code> cannot run together.
<code>--refine</code> wins if both are set.
<code>basic_mps.py</code> is the older PyTorch MPS demo and is not a
FastMetal recipe.
</p>
<div class="cookbook-modes__table-wrap">
<table>
<thead>
<tr>
<th>Mode</th>
<th>CUDA</th>
<th>MLX FastMetal</th>
</tr>
</thead>
<tbody>
<tr>
<td>T2V</td>
<td>FastWan2.1 1.3B, Wan2.2 A14B</td>
<td>1.3B, 5B, and 14B</td>
</tr>
<tr>
<td>I2V</td>
<td>Wan2.1 14B 480P</td>
<td>Not in the released examples</td>
</tr>
<tr>
<td>TI2V</td>
<td>Wan2.2 TI2V 5B</td>
<td>FastMetal 5B is T2V in <code>mlx_wan22_generate.py</code></td>
</tr>
<tr>
<td>Temporal <code>--fast</code></td>
<td>No cookbook recipe</td>
<td>RIFE. Fewer frames, then interpolate to <code>--num-frames</code></td>
</tr>
<tr>
<td>Spatial <code>--fast-spatial</code></td>
<td>No cookbook recipe</td>
<td>Denoise and decode at half resolution, then upsample. No second denoise</td>
</tr>
<tr>
<td>Two-pass <code>--refine</code></td>
<td>No cookbook recipe</td>
<td>Denoise at base resolution, upsample, re-noise, denoise again. Wins over <code>--fast-spatial</code></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Choose the result you want, then use a maintained CUDA or native MLX path.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Wan recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Hardware</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/Wan-AI">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Out of memory on the A14B recipes: the checked-in sources already enable CPU offload; see [Configuration](../inference/configuration.md) for the offload surface before reducing resolution or frames.
- The FastWan2.1 recipe requires `VIDEO_SPARSE_ATTN`; confirm the environment variable in the command was set in the same shell.
- FastMetal MLX: install with `uv pip install -e ".[mlx]"`, then follow the [Apple Silicon guide](../getting_started/installation/mps.md). CUDA FastWan-QAD checkpoints are refused on the MLX runtime.
- FastMetal 5B uses `mlx_wan22_generate.py`. 1.3B and 14B use `mlx_wan_prompt_to_video.py`.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
Every recipe on this page maps to a checked-in FastVideo source. The FastMetal MLX releases include the recorded M4 Max system memory, documented unified-memory floor, and measured peak MLX memory. CUDA entries remain **Source-backed** where the examples record a GPU count but no exact GPU model or VRAM. Unlisted hardware is unknown, not unsupported.
+128
View File
@@ -0,0 +1,128 @@
---
hide:
- toc
---
# Z-Image recipes
<div class="cookbook-shell cookbook-family-page" data-cookbook data-family="zimage" data-recipes="../../assets/cookbook-recipes.json?v=6">
<header class="cookbook-family-header">
<a class="cookbook-back-link" href="../"><span aria-hidden="true">←</span> All model families</a>
<div class="cookbook-family-header__body">
<span class="cookbook-family-header__logo">
<img class="off-glb" src="../../assets/logos/tongyi.webp" alt="Tongyi MAI" width="112" height="112">
</span>
<div>
<p class="cookbook-eyebrow">Maintained family · Inference</p>
<h2>Z-Image inference recipes</h2>
<p>Z-Image Turbo from Tongyi MAI is a fast text-to-image model. The maintained example runs it on a single GPU.</p>
</div>
</div>
<div class="cookbook-lifecycle" aria-label="Lifecycle stages">
<span class="cookbook-lifecycle__stage cookbook-lifecycle__stage--active">Inference <small>live</small></span>
<span class="cookbook-lifecycle__stage">Distillation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Fine-tuning <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Training <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Evaluation <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Optimization <small>planned</small></span>
<span class="cookbook-lifecycle__stage">Deployment <small>planned</small></span>
</div>
</header>
<section class="cookbook-builder" id="recipe-builder" aria-labelledby="builder-heading">
<div class="cookbook-builder__intro">
<h2 id="builder-heading">Pick a recipe and runtime</h2>
<p>Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.</p>
</div>
<div class="cookbook-builder__layout">
<div class="cookbook-controls">
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Recipe</strong>
<span>Task and checkpoint</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--models" data-cookbook-model-options role="group" aria-label="Recipe">
<button type="button" disabled>Loading Z-Image recipes...</button>
</div>
</div>
<div class="cookbook-selection-row">
<div class="cookbook-selection-row__label">
<strong>Runtime</strong>
<span>Maintained paths only</span>
</div>
<div class="cookbook-option-grid cookbook-option-grid--hardware" data-cookbook-hardware-options role="group" aria-label="Runtime">
<button type="button" disabled>Loading runtimes...</button>
</div>
</div>
<p class="cookbook-selection-description" data-cookbook-description>Loading recipe details...</p>
<p class="cookbook-hardware-note">Exact device and memory details appear only when a recorded run supports them.</p>
<div class="cookbook-hardware-state" data-cookbook-hardware-state role="status" aria-live="polite">
Reading recipe evidence...
</div>
</div>
<article class="cookbook-result">
<div class="cookbook-result__header">
<h3 data-cookbook-label>Loading...</h3>
<div class="cookbook-result__badges">
<span class="cookbook-badge">Maintained</span>
<span class="cookbook-badge" data-cookbook-evidence>Source-backed</span>
<span class="cookbook-badge cookbook-badge--neutral" data-cookbook-hardware-badge>Source config</span>
</div>
</div>
<dl class="cookbook-result__facts">
<div><dt>Model</dt><dd data-cookbook-model>Loading...</dd></div>
<div><dt>Workload</dt><dd data-cookbook-task>Loading...</dd></div>
<div><dt>Source configuration</dt><dd data-cookbook-gpus>Loading...</dd></div>
<div><dt>Expected output</dt><dd data-cookbook-artifact>Loading...</dd></div>
</dl>
<div class="cookbook-command">
<div class="cookbook-command__bar">
<span>Terminal</span>
</div>
<pre><code class="language-bash" data-cookbook-command>Loading...</code></pre>
</div>
<div class="cookbook-result__footer">
<a data-cookbook-source href="../../inference/examples/basic/">Open example source</a>
<a data-cookbook-model-link href="https://huggingface.co/Tongyi-MAI">View model card</a>
</div>
<p class="cookbook-picker__status" role="status" aria-live="polite" data-cookbook-status></p>
</article>
</div>
<noscript>
<div class="cookbook-noscript">
JavaScript is needed for the guided selector. You can still browse the
<a href="../../inference/examples/examples_inference_index/">maintained inference examples</a>.
</div>
</noscript>
</section>
</div>
## Before you run
The generated commands expect a local clone:
git clone https://github.com/hao-ai-lab/FastVideo.git
cd FastVideo
Use [Configuration](../inference/configuration.md) for supported Python and
CLI settings, [Optimizations](../inference/optimizations.md) for attention and
memory tradeoffs, and the [support matrix](../inference/support_matrix.md) for
the supported model and optimization surface.
## Troubleshooting
- Output defaults to `outputs/zimage/zimage_turbo.png`; pass `--output` to redirect.
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
## Evidence status
All recipes on this page are **Source-backed**: their commands, model IDs, and flags were validated against the checked-in FastVideo sources listed above (static validation). No runtime GPU validation is recorded for these recipes, so GPU model fit, memory use, throughput, and runtime duration are **Unknown** and deliberately not claimed. Runtime buttons show only the GPU counts configured in checked-in sources.
@@ -80,6 +80,7 @@ surfaces:
VSA_tile_size: "VSA-H3 tile geometry request; model-specific optimization not yet represented in the typed public schema."
inference_torch_compile: "Regional inference compile opt-in currently carried through PipelineSelection.experimental rather than CompileConfig."
vae_parallel_decode: "MiniMax-H3 sequence-parallel VAE decode opt-in; model-specific optimization not yet represented in the typed public schema."
h3_sequential_load: "MiniMax-H3 sequential text-encoder then DiT/VAE load; model-specific optimization not yet represented in the typed public schema."
vae_parallel_encode: "MiniMax-H3 sequence-parallel reference VAE encode opt-in; model-specific optimization not yet represented in the typed public schema."
vae_parallel_decode_strategy: "Chunk-transport collective for vae_parallel_decode; model-specific optimization not yet represented in the typed public schema."
attention_backend: "Process-wide default attention-backend request applied per component at load time; kernel-selection knob not yet represented in the typed public schema."
+21 -2
View File
@@ -30,6 +30,22 @@ requests. HTTP handling and job polling remain asynchronous.
| `GET` | `/health` | Liveness probe |
`POST /v1/videos/generations` remains an alias for older FastVideo clients.
The OpenAI Python and JavaScript clients can create, retrieve, list, download,
and delete video jobs. Use the [H3 server cookbook](../../cookbook/openai-api.md)
for pinned client versions and executable examples. Download variants other
than `video` return HTTP 400. Remix, extensions, and characters are not implemented.
An H3 text-to-video/audio server also serves `/playground/`. This same-origin
browser client uses the video routes above and shares their loaded pipeline.
`GET /playground/config` reports the model alias and operator-explicit sampling
defaults. The playground does not add authentication or a second model process.
For native FastH3 MLX, launch
`python -m fastvideo.entrypoints.openai.mlx_server --config examples/serving/mlx_fasth3.yaml`.
This adapter shares the video-job API and executes pipeline calls on one MLX
thread. It rejects unsupported options before media fetching or job creation.
Image routes are not mounted. MLX keeps its existing phase-memory policy, so a
persistent server does not imply persistent residency of all model weights.
## Video requests
@@ -160,8 +176,11 @@ Errors use the OpenAI envelope:
```
Parse, model-selection, startup-LoRA, and unsupported-parameter failures are
HTTP 400; missing resources are HTTP 404; generation failures are stored on
asynchronous jobs and returned as HTTP 500 when that job is retrieved.
HTTP 400; missing resources are HTTP 404. Generation failures are stored on
asynchronous jobs. Retrieving a failed job returns HTTP 200 with `status: "failed"`
and a string error code, so OpenAI SDK polling returns the terminal resource
instead of retrying it as a transport error. Synchronous generation failures
still return HTTP 500.
Unknown top-level fields are rejected. `extra_params` accepts only the explicit
request-batch passthrough fields supported by the typed request adapter.
+235 -12
View File
@@ -8,6 +8,8 @@ import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
ROOT_DIR = Path(__file__).parent.parent.resolve()
ROOT_DIR_RELATIVE = '../..'
EXAMPLE_DIR = ROOT_DIR / "examples"
@@ -21,21 +23,82 @@ GENERATED_DOC_PREFIXES = (
"distillation/examples/",
)
COOKBOOK_DATA = ROOT_DIR / "docs/assets/cookbook-recipes.json"
COOKBOOK_SERVING_DATA = ROOT_DIR / "docs/assets/cookbook-serving.json"
COOKBOOK_CLIENTS = {
"python": ("video.py", "python -m pip install openai==3.6.0"),
"javascript": ("video.mjs", "npm install openai@7.8.0"),
"curl": ("video.sh", "# Requires curl and jq"),
}
COOKBOOK_SOURCE_ROOTS = (
ROOT_DIR / "examples/inference",
ROOT_DIR / "scripts/inference",
)
# Cookbook families mirror the `model_family` values declared in
# fastvideo/registry.py, plus "flux" for models that register no family
# (e.g. black-forest-labs/FLUX.1-dev) and are grouped for documentation only.
COOKBOOK_FAMILIES = {
"wan",
"turbodiffusion",
"ltx2",
"hunyuan",
"cosmos",
"kandinsky5",
"flux",
"glm_image",
"zimage",
"sd35",
"minimax_h3",
"longcat",
"stable_audio",
"mmaudio",
"matrixgame",
}
# Lifecycle stages a recipe can belong to. Only "inference" has recipes today;
# the rest exist so the schema (and UI) can grow without another migration.
COOKBOOK_STAGES = {
"inference",
"distillation",
"fine-tuning",
"training",
"lora-training",
"evaluation",
"optimization",
"deployment",
}
# Explicit evidence states; never conflate these in recipe entries.
COOKBOOK_EVIDENCE_STATES = {
"Verified",
"Source-backed",
"Estimated",
"Community-reported",
"Unknown",
"Unsupported",
}
COOKBOOK_HARDWARE_EVIDENCE = {"validated", "source-configured", "estimated", "unknown"}
COOKBOOK_HARDWARE_PLATFORMS = {"cuda", "mlx", "mps"}
COOKBOOK_GPU_TYPES = {"NVIDIA", "Apple Silicon"}
COOKBOOK_HARDWARE_TEXT_FIELDS = {
"accelerator",
"system_memory",
"minimum_memory",
"peak_memory",
"evidence_url",
}
def validate_cookbook() -> None:
"""Keep cookbook entries tied to checked-in runnable sources."""
recipes = json.loads(COOKBOOK_DATA.read_text(encoding="utf-8")).get("recipes")
data = json.loads(COOKBOOK_DATA.read_text(encoding="utf-8"))
version = data.get("version")
if not isinstance(version, int):
raise ValueError(f"{COOKBOOK_DATA}: version must be an integer")
recipes = data.get("recipes")
if not isinstance(recipes, list) or not recipes:
raise ValueError(f"{COOKBOOK_DATA}: recipes must be a non-empty list")
seen: set[str] = set()
for recipe in recipes:
required = ("id", "task", "label", "model", "source", "command")
required = ("id", "family", "task", "label", "model", "source", "command")
missing = {key for key in required if not recipe.get(key)}
if missing:
raise ValueError(f"Cookbook recipe is missing: {', '.join(sorted(missing))}")
@@ -43,6 +106,63 @@ def validate_cookbook() -> None:
raise ValueError(f"Duplicate cookbook recipe id: {recipe['id']}")
seen.add(recipe["id"])
if recipe["family"] not in COOKBOOK_FAMILIES:
raise ValueError(f"Cookbook recipe has an unknown family: {recipe['id']}: {recipe['family']}")
stage = recipe.get("stage", "inference")
if stage not in COOKBOOK_STAGES:
raise ValueError(f"Cookbook recipe has an unknown stage: {recipe['id']}: {stage}")
evidence = recipe.get("evidence", "Source-backed")
if evidence not in COOKBOOK_EVIDENCE_STATES:
raise ValueError(f"Cookbook recipe has an unknown evidence state: {recipe['id']}: {evidence}")
hardware = recipe.get("hardware")
if not isinstance(hardware, dict):
raise ValueError(f"Cookbook recipe is missing hardware info: {recipe['id']}")
platform = hardware.get("platform", "cuda")
if platform not in COOKBOOK_HARDWARE_PLATFORMS:
raise ValueError(f"Cookbook recipe has an unknown hardware platform: {recipe['id']}: {platform}")
gpu_count = hardware.get("gpu_count")
if platform == "cuda" and (not isinstance(gpu_count, int) or gpu_count < 1):
raise ValueError(f"CUDA cookbook recipe needs an integer gpu_count >= 1: {recipe['id']}")
if platform != "cuda" and gpu_count is not None:
raise ValueError(f"Non-CUDA cookbook recipe must not use gpu_count: {recipe['id']}")
hardware_evidence = hardware.get("evidence")
if hardware_evidence not in COOKBOOK_HARDWARE_EVIDENCE:
raise ValueError(f"Cookbook recipe has unknown hardware evidence: {recipe['id']}: {hardware_evidence}\n"
f"Expected one of {sorted(COOKBOOK_HARDWARE_EVIDENCE)}. Never guess GPU compatibility.")
for field_name in COOKBOOK_HARDWARE_TEXT_FIELDS:
value = hardware.get(field_name)
if value is not None and not (isinstance(value, str) and value.strip()):
raise ValueError(f"Cookbook hardware field must be a non-empty string: {recipe['id']}: {field_name}")
if hardware_evidence == "validated" and not hardware.get("accelerator"):
raise ValueError(f"Validated cookbook hardware needs an exact accelerator: {recipe['id']}")
if hardware_evidence == "source-configured":
recorded_fields = {"accelerator", "system_memory", "peak_memory"}.intersection(hardware)
if recorded_fields:
raise ValueError(f"Source-configured hardware cannot claim recorded run details: {recipe['id']}: "
f"{', '.join(sorted(recorded_fields))}")
evidence_url = hardware.get("evidence_url")
if evidence_url is not None and not evidence_url.startswith("https://github.com/hao-ai-lab/FastVideo/"):
raise ValueError(f"Cookbook hardware evidence must link to the FastVideo repository: {recipe['id']}")
gpu_types = recipe.get("gpu_types", [])
if not isinstance(gpu_types, list) or any(gpu not in COOKBOOK_GPU_TYPES for gpu in gpu_types):
raise ValueError(f"Cookbook recipe has an unknown gpu_types entry: {recipe['id']}: {gpu_types}")
revision = recipe.get("revision")
if revision is not None and not (isinstance(revision, str) and revision.strip()):
raise ValueError(f"Cookbook recipe revision must be a non-empty string when present: {recipe['id']}")
related = recipe.get("related", [])
if not isinstance(related, list):
raise ValueError(f"Cookbook recipe related must be a list of recipe ids: {recipe['id']}")
modes = recipe.get("modes")
if modes is not None and (not isinstance(modes, list) or not modes
or any(not isinstance(item, str) or not item.strip() for item in modes)):
raise ValueError(f"Cookbook recipe modes must be a non-empty list of strings: {recipe['id']}")
source = (ROOT_DIR / recipe["source"]).resolve()
if not any(source.is_relative_to(root.resolve()) for root in COOKBOOK_SOURCE_ROOTS):
raise ValueError(f"Cookbook source is outside an approved directory: {recipe['source']}")
@@ -50,11 +170,102 @@ def validate_cookbook() -> None:
raise ValueError(f"Cookbook source does not exist: {recipe['source']}")
source_text = source.read_text(encoding="utf-8")
if recipe["model"] not in source_text:
raise ValueError(f"Cookbook model is not present in {recipe['source']}: {recipe['model']}")
# The model must be traceable to the checked-in source itself, or be
# passed explicitly on the command line (e.g. --model-path <model> or
# MODEL_PATH=<model>) when the source reads it from arguments/env.
if recipe["model"] not in source_text and recipe["model"] not in recipe["command"]:
raise ValueError(f"Cookbook model is not present in {recipe['source']} or its command: {recipe['id']}")
if recipe["source"] not in recipe["command"]:
raise ValueError(f"Cookbook command does not invoke its source: {recipe['id']}")
if "serving" in recipe:
cookbook_serving_profile(recipe)
# Second pass so `related` may point forward at recipes defined later.
ids = {recipe["id"] for recipe in recipes}
for recipe in recipes:
for related_id in recipe.get("related", []):
if related_id not in ids:
raise ValueError(f"Cookbook recipe references unknown related id: {recipe['id']}: {related_id}")
cache_bust = f"cookbook-recipes.json?v={version}"
cookbook_pages = ROOT_DIR / "docs/cookbook"
for page in sorted(cookbook_pages.glob("*.md")):
text = page.read_text(encoding="utf-8")
if "cookbook-recipes.json" not in text:
continue
if cache_bust not in text:
raise ValueError(f"{page}: recipe JSON cache-bust must be {cache_bust}")
def cookbook_serving_profile(recipe: dict) -> dict:
"""Read server facts separately from the local inference run's evidence."""
serving = recipe["serving"]
if not isinstance(serving, dict) or not serving.get("source") or not serving.get("install"):
raise ValueError(f"Serving recipe needs source and install fields: {recipe['id']}")
source = (ROOT_DIR / serving["source"]).resolve()
if not source.is_relative_to(ROOT_DIR / "examples/serving") or not source.is_file():
raise ValueError(f"Serving config must exist under examples/serving: {recipe['id']}")
config = yaml.safe_load(source.read_text(encoding="utf-8"))
runtime = config.get("runtime", "cuda")
if runtime not in {"cuda", "mlx"} or runtime != recipe["hardware"].get("platform", "cuda"):
raise ValueError(f"Serving runtime does not match recipe: {recipe['id']}")
generator = config["generator"]
if generator["model_path"] != recipe["model"]:
raise ValueError(f"Serving checkpoint does not match recipe: {recipe['id']}")
if config.get("streaming") is not None:
raise ValueError(f"Cookbook OpenAI serving requires a REST config: {recipe['id']}")
server = config["server"]
model = server["served_model_name"]
if not isinstance(model, str) or not re.fullmatch(r"[A-Za-z0-9._/-]+", model):
raise ValueError(f"Serving model alias must be safe for client examples: {recipe['id']}")
port = server["port"]
if type(port) is not int or not 1 <= port <= 65535:
raise ValueError(f"Serving config needs a valid port: {recipe['id']}")
hardware = {"platform": runtime, "evidence": "source-configured"}
if runtime == "cuda":
count = generator["engine"]["num_gpus"]
if type(count) is not int or count < 1:
raise ValueError(f"Serving config needs a valid GPU count: {recipe['id']}")
hardware["gpu_count"] = count
command = f"fastvideo serve --config {serving['source']} --server.host 127.0.0.1"
else:
if server.get("host") != "127.0.0.1":
raise ValueError(f"MLX cookbook server must bind to loopback: {recipe['id']}")
command = f"python -m fastvideo.entrypoints.openai.mlx_server --config {serving['source']}"
base_url = f"http://127.0.0.1:{port}/v1"
clients = {}
for language, (filename, install) in COOKBOOK_CLIENTS.items():
path = ROOT_DIR / "examples/serving/clients" / filename
text = path.read_text(encoding="utf-8")
# Keep displayed snippets identical to executable sources for the
# checked-in H3 profile, with only endpoint and alias substitutions.
text = text.replace("http://127.0.0.1:8000/v1", base_url)
text = text.replace('"fasth3"', json.dumps(model)).replace("${FASTVIDEO_MODEL:-fasth3}",
"${FASTVIDEO_MODEL:-" + model + "}")
clients[language] = {"source": path.relative_to(ROOT_DIR).as_posix(), "code": text, "install": install}
return {
"source": serving["source"],
"install": serving["install"],
"command": command,
"runtime": runtime,
"prepare": serving.get("prepare", ""),
"model": model,
"base_url": base_url,
"playground_url": f"http://127.0.0.1:{port}/playground/",
"health_command": f"curl --fail-with-body http://127.0.0.1:{port}/health",
"hardware": hardware,
"sampling": config["default_request"]["sampling"],
"clients": clients,
}
def generate_cookbook_serving() -> None:
"""Publish executable clients and config facts through the docs build."""
data = json.loads(COOKBOOK_DATA.read_text(encoding="utf-8"))
profiles = {recipe["id"]: cookbook_serving_profile(recipe) for recipe in data["recipes"] if "serving" in recipe}
COOKBOOK_SERVING_DATA.write_text(json.dumps(profiles, indent=2) + "\n", encoding="utf-8")
def fix_case(text: str) -> str:
subs = {
@@ -194,8 +405,18 @@ class Example:
""" # noqa: E501
if self.path.is_file():
return []
is_other_file = lambda file: file.is_file() and file != self.main_file
return [file for file in self.path.rglob("*") if is_other_file(file)] # type: ignore[no-untyped-call]
other_files = []
for directory, subdirectories, filenames in os.walk(self.path):
# Local client dependencies and caches are not example sources.
subdirectories[:] = [
name for name in subdirectories
if not name.startswith(".") and name not in {"node_modules", "__pycache__"}
]
for name in filenames:
file = Path(directory) / name
if not name.startswith(".") and file != self.main_file:
other_files.append(file)
return other_files
def determine_title(self) -> str:
return fix_case(self.path.stem.replace("_", " ").title())
@@ -455,7 +676,7 @@ def generate_flat_examples(examples: list[Example], category_indices: dict[str,
# Generate the example documentation
doc_path = index.path.parent / f"{example.path.stem}.md"
with open(doc_path, "w+") as f:
with open(doc_path, "w+", encoding="utf-8") as f:
f.write(example.generate())
index.documents.append(example.path.stem)
@@ -482,7 +703,7 @@ def generate_nested_examples(nested_structures: dict[str, dict[str, dict[str, di
# Generate dataset examples using the Example class
for dataset, nested_struct in datasets.items():
doc_path = category_base_dir / f"{nested_struct.filename}.md"
with open(doc_path, "w+") as f:
with open(doc_path, "w+", encoding="utf-8") as f:
f.write(nested_struct.example.generate())
# Create model-level index
@@ -497,14 +718,14 @@ def generate_nested_examples(nested_structures: dict[str, dict[str, dict[str, di
model_index.documents.append(nested_struct.filename)
# Write model index
with open(model_index.path, "w+") as f:
with open(model_index.path, "w+", encoding="utf-8") as f:
f.write(model_index.generate())
# Add model to method index
method_index.documents.append(model)
# Write method index
with open(method_index.path, "w+") as f:
with open(method_index.path, "w+", encoding="utf-8") as f:
f.write(method_index.generate())
# Add method to main category index
@@ -557,12 +778,12 @@ def generate_examples(generate_main_index: bool = False) -> None:
examples_index.documents.insert(0, str(rel_path).replace("\\", "/").replace(".md", ""))
# Write the category index file
with open(category_index.path, "w+") as f:
with open(category_index.path, "w+", encoding="utf-8") as f:
f.write(category_index.generate())
# Write the main index file if requested
if generate_main_index and examples_index:
with open(examples_index.path, "w+") as f:
with open(examples_index.path, "w+", encoding="utf-8") as f:
f.write(examples_index.generate())
@@ -572,6 +793,7 @@ def on_pre_build(config, **kwargs):
This function is called automatically by MkDocs' native hook system.
"""
validate_cookbook()
generate_cookbook_serving()
print("Generating example documentation...")
generate_examples(generate_main_index=True)
print("Example documentation generated successfully!")
@@ -586,6 +808,7 @@ def on_page_context(context, page, **kwargs):
if __name__ == "__main__":
validate_cookbook()
generate_cookbook_serving()
print("Generating example documentation...")
generate_examples(generate_main_index=True)
print("Example documentation generated successfully!")
+58 -3
View File
@@ -153,6 +153,20 @@ python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py \
--formats "int6"
```
Dense conversion drops the trained VSA gate projections. To keep them (INT6
weight-only, same affine grid as the other linear matrices) write a **new**
directory:
```bash
python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py \
--model-root ./FastH3-Preview-v0.2/transformer \
--out ./FastH3-MLX-vsa \
--formats "int6" \
--include-vsa
```
Do not overwrite an existing dense export such as `./FastH3-MLX/int6`.
Run the baseline path:
```bash
@@ -178,10 +192,51 @@ python examples/inference/basic/mlx_fasth3.py \
--output-path ./outputs/fasth3_int6_fast_720p.mp4
```
Add `--fast-spatial` for spatial fast mode, `--fast`'s spatial twin. It
denoises and decodes on the smallest 32px-aligned canvas covering the
requested size divided by `--fast-spatial-scale` (a 480x832 request runs on a
256x416 canvas), then resamples the decoded frames up to the requested size
in pixel space. It composes with `--fast`. This is a speed/quality trade-off
and stays off by default: the output carries the reduced canvas's detail
budget, so it reads softer than a native-resolution render, with the unsharp
pass countering some but not all of the difference:
```bash
python examples/inference/basic/mlx_fasth3.py \
--model-root ./FastH3-Preview-v0.2 \
--mlx-checkpoint ./FastH3-MLX/int6 \
--prompt "(S1) A presenter says <d>[English] Fast H3 is fastest.</d>" \
--height 480 --width 832 --num-frames 124 --seed 2028 \
--fast --fast-spatial \
--output-path ./outputs/fasth3_int6_fast_spatial.mp4
```
VSA is off by default. A dense-only checkpoint (no `--include-vsa`) keeps the
existing fused-SDPA path. After converting with `--include-vsa`, enable the
sparse path explicitly:
```bash
python examples/inference/basic/mlx_fasth3.py \
--model-root ./FastH3-Preview-v0.2 \
--mlx-checkpoint ./FastH3-MLX-vsa/int6 \
--vsa --vsa-sparsity 0.9 --vsa-tile-size 64 --vsa-prefix-mode exempt \
--prompt "(S1) A presenter says <d>[English] Fast H3 is amazing.</d>" \
--height 720 --width 1280 --num-frames 124 --seed 2026 \
--output-path ./outputs/fasth3_int6_vsa_720p.mp4
```
`--vsa-impl auto` uses the chunked gather+SDPA **reference** path.
`--vsa-impl simd` is an opt-in SIMD-group kernel (tile 64, head dim 128) that
falls back to reference on unsupported shapes. It is not the default.
`--vsa-impl reference` is the same as `auto`.
!!! note "Current MLX scope"
This source runtime supports T2VA and temporal `--fast`. FL2VA, Ref2VA,
spatial fast mode, two-pass refinement, VSA, and `VideoGenerator`
registry dispatch are not wired yet. The checkpoint uses the MiniMax H3
This source runtime supports T2VA, temporal `--fast`, spatial
`--fast-spatial`, and opt-in VSA. FL2VA, Ref2VA, two-pass refinement, and
`VideoGenerator` registry dispatch are not wired yet. INT8/INT6/INT4
quantization is **weight-only**; VSA attention Q/K/V stay BF16. Old dense
MLX checkpoints remain valid for dense inference and raise a reconvert
error if `--vsa` is set. The checkpoint uses the MiniMax H3
Community License; review the model card before use or redistribution.
## Development Environment Setup
@@ -161,6 +161,15 @@ is power-cycled. To avoid it:
on: "CPU" offload uses the same unified RAM. Multi-GPU FSDP sharding remains
available because it partitions weights without parking them in a separate
host pool.
- **MiniMax H3 / FastH3** still needs sequential loading on one GB10. The Qwen3-VL
conditioner is tens of gigabytes of BF16. If the DiT and VAEs load while that
encoder is still resident, the process is a typical `earlyoom` kill (Python is
preferred). `h3_sequential_load` defaults to auto and turns this split on for
unified-memory devices. Do not pass `--no-h3-sequential-load` here. Force
`--h3-sequential-load` only if auto-detect misses the device. The CUDA pipeline
encodes first, releases the encoder, then loads DiT and VAEs onto the
accelerator (`to_cpu` follows `cpu_offload`, which is off here). See
[Offloading](../../inference/offloading.md).
## Gotchas specific to the GB10
@@ -177,6 +186,11 @@ A few things that surprise people on this box (beyond the memory notes above):
- **Cosmos-2.5** uses a Qwen2.5-VL text encoder; make sure you're on a FastVideo
build recent enough to include its `transformers`-compatibility handling before
running it.
- **MiniMax H3 worker init can look healthy and still die on the first generate**
if sequential load is off (`--no-h3-sequential-load`, or auto-off on a
misclassified device) and encoder, VAE, and DiT load together. Confirm the log
contains `Released MiniMax-H3 text encoder after conditioning` before
`Loading MiniMax-H3 denoise modules`.
## Reproduce these numbers
+157
View File
@@ -0,0 +1,157 @@
# Decode H3 previews with TAEH3 on MLX
TAEH3 is an optional tiny video decoder for MiniMax H3. It replaces only
video reconstruction. The denoiser, sampler, resolution, frame count, and
audio decoder stay unchanged. The full H3 VAE remains the default.
TAEH3 produces a different reconstruction. Fine fur, fabric, vegetation,
and surface textures can look softer. Use it for previews or when you accept
that tradeoff. It is not a lossless acceleration of the full VAE.
## Generate a video
Use your existing MLX FastH3 environment and converted checkpoint:
```bash
python examples/inference/basic/mlx_fasth3.py \
--model-root ~/models/FastH3-Preview-v0.2 \
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
--prompt 'A red panda beside a mountain lake at sunrise.' \
--height 480 --width 832 --num-frames 124 --steps 4 --seed 2027 \
--video-decode-backend taeh3 --vae-dtype fp32 \
--output-path video_samples/taeh3_preview.mp4
```
The first run downloads a 22.7 MB safetensors checkpoint from an immutable
upstream revision and verifies its SHA-256 digest. No remote Python code runs.
The cache is `~/.cache/fastvideo/taehv/taeh3.safetensors`.
Use `--taeh3-checkpoint /path/to/taeh3.safetensors` for offline use or a custom
trained checkpoint. Custom files must match the decoder architecture; they
are not required to match the upstream digest.
| Option | Default | Behavior |
| --- | --- | --- |
| `--video-decode-backend` | `h3-vae` | Select `taeh3` for approximate decoding. |
| `--taeh3-checkpoint` | Unset | Use the pinned upstream checkpoint from cache. |
| `--taeh3-chunk-size` | `5` | Latent frames per execution chunk. Smaller chunks reduce feature memory. |
| `--vae-dtype` | `fp32` | Decoder computation dtype. FP16 and BF16 are separate numerical tradeoffs. |
`--tiled-video-decode` controls the full VAE only. TAEH3 uses the whole spatial
canvas and bounded temporal chunks. Its memory blocks carry state across
chunks. The pipeline reports `video_decode_backend`, decode timing, and MLX
peak memory alongside the existing generation metrics.
The mode composes with `--fast-spatial` and temporal `--fast`. Those options
change the denoising workload and have additional quality costs. A TAEH3-only
measurement does not establish the quality or speed of a combined mode.
## Latent contract
The native decoder reads normalized diffusion latents in NCTHW layout through
`decode_latents_taeh3_mlx`. Do not apply the full VAE's mean, standard deviation,
or pixel denormalization. Its 24 latent channels reconstruct RGB at 16 times
the latent spatial dimensions.
H3 uses latent lengths `5*k-3`, such as 2, 7, and 37. TAEH3 removes three raw
frames from each group of 20 decoder outputs. Thus 37 latent frames produce
124 RGB frames. The port validates that contract before returning output.
## Provenance and validation
Architecture and weights come from Ollin Boer Bohan's MIT-licensed
[TAEHV H3 implementation](https://github.com/madebyollin/taehv/commit/62f7591f59dfbb4c3c02b7a621d180a9eeaba26c).
The [Aryan fork](https://github.com/aryan5v/taehv/tree/aryan/first-class-taeh3)
adds an explicit `TAEH3` API and checkpoint-loading tests. The fork is not an
official MiniMax release and does not contain newly trained H3 weights.
Run the numerical tests against a local TAEHV checkout containing the released
weights:
```bash
TAEH3_REFERENCE_DIR=/path/to/taehv \
python -m pytest fastvideo/tests/mlx/test_mlx_taeh3.py -q
```
Tests compare MLX FP32 with upstream sequential FP32 and parallel FP64 at
`atol=1e-5, rtol=1e-5`. The initial parallel CPU FP32 comparison failed that
strict gate, reaching about `4e-5` maximum error on a 37-latent small fixture.
CPU convolution rounding changes with its batch size. The FP64 reference
and sequential FP32 checks distinguish this from a temporal chunking error.
The original failed comparison is not reported as a pass.
Passing these tests means the MLX port agrees with the tiny decoder within
the specified tolerance. It does not mean TAEH3 matches the full H3 VAE.
## Compare decoders without another denoising run
Save the normalized packed `video_rows` returned by `pipeline.denoise` as
`np.savez("latents.npz", video=video_rows)`. Then run:
```bash
python examples/inference/basic/mlx_h3_decode_benchmark.py \
--latents latents.npz \
--model-root ~/models/FastH3-Preview-v0.2 \
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
--height 480 --width 832 --num-frames 124 \
--output-dir outputs/taeh3_comparison
```
Use a fresh output directory. The benchmark writes the first decoded frame
arrays and a JSON report with the input digest, MLX version, device, per-trial
latency, MLX peak active memory, lifetime process peak RSS, and swap snapshots.
Decoder loading is included; first-time checkpoint downloading is excluded.
Only run one MLX workload at a time. `--repeats` reverses the decoder order on
alternate trials. Do not treat two memory counters as additive or infer zero
page-outs from unchanged swap snapshots.
## Measured decoder results
On an Apple M4 Max with 36 GB unified memory, MLX 0.32.2, FP32 decoding,
and five-latent execution chunks:
| Workload | Full H3 VAE | TAEH3 |
| --- | --- | --- |
| Saved 37-frame latents to 124 RGB frames, 832x480 | 107.90 s | 1.44 s |
| MLX peak active memory for that decode | 11.03 GiB | 3.62 GiB |
These are one matched pair using the same seed-2027 production latent file,
including decoder loading. Both swap snapshots stayed unchanged. The decoded
images differ: PSNR against the full VAE was 29.86 dB, and inspected frames
showed softer fine detail. The approximately 75x ratio applies only to this
decoder comparison, not the entire generation pipeline.
Eight additional TAEH3-only decodes measured 0.96 s on first use and a 0.98 s median across seven warm trials, ranging from 0.96 to 0.99 s. Decoder construction and weight loading were included in each trial. The full VAE was not repeated eight times.
### Native resolution with temporal fast
A separate uncached run with `--fast --video-decode-backend taeh3`, without
spatial fast, completed in **205.47 s wall time**. It kept the native 832x480
canvas, denoised 73 source frames, and used RIFE to produce 124 output frames
with full-duration audio. Seed 2027, four steps, dense attention, INT6.
Prompt encoding took 16.66 s, denoising 181.14 s, TAEH3 decoding 0.64 s, RIFE
5.23 s, audio decoding 0.73 s, and muxing 0.40 s. Peak denoise MLX allocation
was 17.87 GiB. System swap use rose from 1231.19 to 2753.75 MiB.
The output has 124 H.264 frames at 832x480 and stereo AAC at 32 kHz.
This is one combined-mode measurement. It preserves spatial resolution but
still combines frame interpolation with approximate decoding. Frame samples
retain more fine detail than the spatial fast experiment below. Motion and
speech need human review; do not infer native dense generation parity.
### Spatial fast experiment, not the preferred quality path
A separate uncached generation with `--fast-spatial --video-decode-backend
taeh3` produced a 124-frame, 832x480 MP4 with stereo audio in **95.75 s wall
time**. Seed 2028, four denoising calls, INT6 weights, dense attention, and no
temporal fast mode were used. The internal canvas was 416x256, then cropped
and upscaled to the requested output size.
That run spent 16.66 s encoding the prompt, 77.05 s denoising, 0.34 s decoding
video, 0.30 s upscaling, 0.73 s decoding audio, and 0.40 s muxing. Peak denoise
MLX allocation was 16.85 GiB. System swap use rose from 1041.94 to 1247.19 MiB;
this measurement does not attribute that increase to a particular process.
It is one end-to-end result, not a repeated benchmark or native-resolution
quality comparison. The reduced canvas visibly loses detail, especially in
the opening frames. Speech intelligibility and motion quality need human
review before treating this combination as a final-output preset.
+32
View File
@@ -21,6 +21,19 @@ pool there, so offload adds transfers and duplicate residency instead of freeing
memory. CUDA FSDP sharding remains enabled when requested; MPS continues to
disable FSDP. `pin_cpu_memory` is not an offload mode and is left unchanged.
MiniMax H3 CUDA inference can use a second lever that does not copy weights to a
host pool: load the Qwen3-VL text encoder, run conditioning, then release that
encoder before loading the DiT and video/audio VAEs. `h3_sequential_load`
defaults to auto (`None`): on for unified-memory devices such as GB10, off on
discrete GPUs. Pass `--h3-sequential-load` to force it, or
`--no-h3-sequential-load` to keep the encoder resident for later `generate()`
calls on the same worker. Sequential load currently cannot re-encode a new prompt
on that worker; start a new generator until prompt-cache reload exists. The MLX
FastH3 runtime always uses this phase order. When host offload is off, DiT
safetensors are read onto the accelerator instead of CPU-then-copy.
Input-preparation geometry (spatial ratio, latent channels, audio sample rate)
comes from the VAE arch configs until those weights load.
## Behavior Explanation
!!! note
@@ -65,6 +78,25 @@ This option introduces negligible performance overhead.
We recommend enabling this option for single GPU usage. This option is not compatible with FSDP.
### `h3_sequential_load`
MiniMax H3 only. When enabled, the pipeline loads Qwen3-VL, runs conditioning,
releases that encoder, then loads the DiT and VAEs. Default is auto: enabled on
unified-memory accelerators, disabled on discrete GPUs.
#### Performance Impact
On GB10 / Spark this is required so encoder, DiT, and VAE weights are not all
resident in one unified pool. On discrete GPUs (for example 4×GB200) sequential
load is unnecessary and it blocks a second `generate()` on the same worker
because the encoder has been released.
#### Usage Recommendation
Leave the default on Spark / DGX Spark. Force `--h3-sequential-load` only when
you need the split on a discrete GPU. Use `--no-h3-sequential-load` when you
need more than one prompt per worker and have enough memory to keep the encoder.
### `text_encoder_cpu_offload`
When enabled, the text encoder model weights are offloaded to CPU memory, and text encoding is computed on CPU.
+2 -2
View File
@@ -183,9 +183,9 @@ optimizations: absence means **untested**, not incompatible.
| Release path | Model | Mode | Validated hardware | Status |
| --- | --- | --- | --- | --- |
| MLX FastMetal T2V 1.3B | [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD) | 480x832, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
| MLX FastMetal TI2V 5B | [`FastVideo/FastMetal-5B-QAD`](https://huggingface.co/FastVideo/FastMetal-5B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
| MLX FastMetal T2V 5B | [`FastVideo/FastMetal-5B-QAD`](https://huggingface.co/FastVideo/FastMetal-5B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode; optional `--fast`, `--fast-spatial`, `--refine`. The checked-in example is T2V. CUDA Wan2.2 TI2V 5B is the image-capable path. | Apple M4 Max, 16 GB+ unified memory | Released |
| MLX FastMetal T2V 14B | [`FastVideo/FastMetal-14B-QAD`](https://huggingface.co/FastVideo/FastMetal-14B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 36 GB+ unified memory | Released |
| MLX FastH3 Preview T2VA | [`FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2`](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) + locally converted DiT | 480p / 720p, 124 frames, 4-step DMD2, INT8/INT6/INT4 DiT, native video + audio VAE; optional temporal RIFE fast mode | Apple M4 Max, 36 GB unified memory | Source runtime; T2VA only |
| MLX FastH3 Preview T2VA | [`FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2`](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) + locally converted DiT | 480p / 720p, 124 frames, 4-step DMD2, INT8/INT6/INT4 **weight-only** DiT, native video + audio VAE; optional temporal RIFE fast mode; optional spatial fast mode; optional VSA (tile 64/256, exempt/compete) on `--include-vsa` checkpoints | Apple M4 Max, 36 GB unified memory | Source runtime; T2VA only |
Apple Silicon uses the native MLX runtime. FastMetal-QAD is the packaged Wan
release, while FastH3 Preview currently uses a source checkout and local DiT
+11 -4
View File
@@ -51,9 +51,16 @@ python examples/inference/basic/mlx_fasth3.py \
--output-path ./outputs/fasth3_int6.mp4
```
Pass `--fast` for temporal RIFE fast mode. This MLX entrypoint currently
supports T2VA only; FL2VA, Ref2VA, spatial fast mode, and two-pass refinement
remain follow-up work. The complete setup and conversion commands are in the
Pass `--fast` for temporal RIFE fast mode and `--fast-spatial` for spatial
fast mode (reduced-canvas denoise + pixel-space upsample); the two compose.
VSA is opt-in: convert with `--include-vsa` and pass `--vsa` (see the
[Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/)).
This MLX entrypoint currently supports T2VA only; FL2VA, Ref2VA, and
two-pass refinement remain follow-up work. INT6/INT8/INT4 are
weight-only; VSA attention activations stay BF16. Dense-only checkpoints keep
working for dense inference.
The complete setup and conversion commands are in the
[Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
For an example running DMD+VSA inference:
@@ -111,7 +118,7 @@ python examples/inference/basic/basic_fasth3.py \
--warmup-seed 999
```
`all` enables the inference-only H3 fusions and regional compile. Both can change floating-point operation order, so this is a report-only performance profile rather than an exact-parity route. Use `--profile strict` to disable the H3 fusions while preserving regional compile, or `--profile strict --no-inference-torch-compile` for the eager strict route. Individual `--no-*` switches are available for portability and attribution; in particular, use `--vsa-kernel triton --no-fa4` if the Blackwell kernels are unavailable. The script preserves the warmup and each measured video under distinct paths, then prints per-request wall time plus a warmup-excluded median.
`all` enables the inference-only H3 fusions and regional compile. Both can change floating-point operation order, so this is a report-only performance profile rather than an exact-parity route. Use `--profile strict` to disable the H3 fusions while preserving regional compile, or `--profile strict --no-inference-torch-compile` for the eager strict route. Individual `--no-*` switches are available for portability and attribution; in particular, use `--vsa-kernel triton --no-fa4` if the Blackwell kernels are unavailable. `--h3-sequential-load` / `--no-h3-sequential-load` override the auto split that releases Qwen3-VL before DiT/VAE load (on by default on GB10, off on discrete GPUs). The script preserves the warmup and each measured video under distinct paths, then prints per-request wall time plus a warmup-excluded median.
One script covers each validated duration; regional compile is the fastest
measured DiT route for all three:
+7
View File
@@ -99,6 +99,11 @@ def build_parser(description: str | None = None) -> argparse.ArgumentParser:
action=argparse.BooleanOptionalAction,
default=True,
help="round-robin VAE temporal chunks across sequence-parallel ranks")
parser.add_argument("--h3-sequential-load",
action=argparse.BooleanOptionalAction,
default=None,
help="encode with Qwen3-VL, release it, then load DiT/VAEs. Default auto: on for "
"unified-memory devices (GB10), off on discrete GPUs")
parser.add_argument("--replicated-dit",
action=argparse.BooleanOptionalAction,
default=True,
@@ -229,6 +234,8 @@ def build_generator_config(args: argparse.Namespace) -> GeneratorConfig:
"vae_parallel_decode": args.parallel_vae,
"vae_parallel_decode_strategy": "gather",
}
if args.h3_sequential_load is not None:
experimental["h3_sequential_load"] = args.h3_sequential_load
if use_vsa:
experimental.update({
"VSA_sparsity": args.vsa_sparsity,
+112 -1
View File
@@ -19,8 +19,22 @@ denoising fewer video frames, then uses MLX RIFE 4.25 to reconstruct the
requested frame count. A 1280x720 request runs on H3's 1280x736 grid and is
center-cropped after decode.
``--fast-spatial`` is spatial fast mode, ``--fast``'s spatial twin. It
denoises and decodes on the smallest 32px-aligned canvas covering
height/width divided by ``--fast-spatial-scale``, then resamples the decoded
frames up to the requested size in pixel space. The two modes compose.
This trades fine detail for speed: the output carries the reduced canvas's
detail budget and reads softer than a native-resolution render, so it stays
off by default.
This entrypoint currently supports text-to-video-with-audio only. It does not
yet wire FL2VA, Ref2VA, spatial fast mode, or two-pass refinement.
yet wire FL2VA, Ref2VA, or two-pass refinement.
VSA is off by default; existing dense MLX checkpoints remain supported.
H3 uses fused MLX RMSNorm, which can change BF16 rounding compared with the
older explicit normalization path. Convert with ``--include-vsa`` and pass
``--vsa`` to enable the sparse path. Attention activations stay BF16; INT6/INT8/INT4 apply only to
linear weights, including the optional gate projection.
"""
from __future__ import annotations
@@ -30,6 +44,13 @@ import json
from pathlib import Path
def _dense_layers(value: str) -> tuple[int, ...]:
layers = tuple(int(part.strip()) for part in value.split(",") if part.strip())
if any(layer < 0 for layer in layers):
raise argparse.ArgumentTypeError("dense layer indices must be non-negative")
return layers
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -58,9 +79,31 @@ def parse_args() -> argparse.Namespace:
help="temporal reduction target for --fast (default: 2)")
parser.add_argument("--fast-sharpen", type=float, default=0.6,
help="unsharp strength after RIFE interpolation (0 disables)")
parser.add_argument(
"--fast-spatial",
action=argparse.BooleanOptionalAction,
default=False,
help="denoise and decode at height/width // fast-spatial-scale on H3's 32px grid, "
"then resample the decoded frames up to the requested size; composes with --fast. "
"Trades fine detail for speed",
)
parser.add_argument("--fast-spatial-scale", type=int, default=2,
help="spatial reduction factor for --fast-spatial (default: 2)")
parser.add_argument("--fast-spatial-upsample-mode",
choices=("lanczos", "cubic", "bilinear", "nearest"),
default="lanczos",
help="pixel interpolation kernel for the post-decode upsample")
parser.add_argument("--fast-spatial-sharpen", type=float, default=0.4,
help="unsharp strength after the upsample (0 disables)")
parser.add_argument("--rife-weights-dir", type=Path, default=None,
help="optional local mlx-community/RIFE-4.25 snapshot")
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
parser.add_argument("--video-decode-backend", choices=("h3-vae", "taeh3"), default="h3-vae",
help="full H3 VAE or approximate TAEH3 preview decoder; audio is unchanged")
parser.add_argument("--taeh3-checkpoint", type=Path, default=None,
help="local TAEH3 safetensors; otherwise download hash-verified upstream weights")
parser.add_argument("--taeh3-chunk-size", type=int, default=5,
help="latent frames per TAEH3 chunk; causal memory persists across chunks")
parser.add_argument("--prompt-cache-dir", type=Path, default=None,
help="directory for reusable prompt embedding caches")
parser.add_argument(
@@ -69,6 +112,58 @@ def parse_args() -> argparse.Namespace:
default=True,
help="decode with the reference 256px overlapping VAE tiles (disable only for diagnostics)",
)
parser.add_argument(
"--vsa",
action=argparse.BooleanOptionalAction,
default=False,
help=(
"enable MiniMax H3 VSA; requires a VSA-capable MLX checkpoint "
"from --include-vsa"
),
)
parser.add_argument(
"--vsa-sparsity",
type=float,
default=0.9,
help="VSA sparsity in [0, 1); 0.9 is the trained FastH3 policy",
)
parser.add_argument(
"--vsa-tile-size",
type=int,
default=64,
choices=(64, 256),
help="VSA tile size in tokens",
)
parser.add_argument(
"--vsa-prefix-mode",
choices=("exempt", "compete"),
default="exempt",
help=(
"prefix-key policy: always keep (exempt) or FLOP-matched top-k "
"(compete)"
),
)
parser.add_argument(
"--vsa-dense-first-n-steps",
type=int,
default=0,
help="run the first N denoise steps dense",
)
parser.add_argument(
"--vsa-dense-layers",
type=_dense_layers,
default=(),
help="comma-separated layer indices forced dense",
)
parser.add_argument(
"--vsa-impl",
choices=("auto", "reference", "simd"),
default="auto",
help=(
"sparse attention implementation; auto uses chunked gather+SDPA "
"(simd is opt-in)"
),
)
return parser.parse_args()
@@ -80,6 +175,9 @@ def main() -> None:
model_root=args.model_root,
mlx_dit_checkpoint=args.mlx_checkpoint,
vae_dtype=args.vae_dtype,
video_decode_backend=args.video_decode_backend,
taeh3_checkpoint=args.taeh3_checkpoint,
taeh3_chunk_size=args.taeh3_chunk_size,
prompt_cache_dir=args.prompt_cache_dir,
)
result = pipeline.generate(
@@ -95,11 +193,24 @@ def main() -> None:
fast_factor=args.fast_factor,
fast_sharpen=args.fast_sharpen,
rife_weights_dir=args.rife_weights_dir,
fast_spatial=args.fast_spatial,
fast_spatial_scale=args.fast_spatial_scale,
fast_spatial_upsample_mode=args.fast_spatial_upsample_mode,
fast_spatial_sharpen=args.fast_spatial_sharpen,
vsa=args.vsa,
vsa_sparsity=args.vsa_sparsity,
vsa_tile_size=args.vsa_tile_size,
vsa_prefix_mode=args.vsa_prefix_mode,
vsa_dense_first_n_steps=args.vsa_dense_first_n_steps,
vsa_dense_layers=args.vsa_dense_layers,
vsa_impl=args.vsa_impl,
)
print(json.dumps({
"video_path": result.video_path,
"timings_s": {k: round(v, 2) for k, v in result.timings.items()},
"peak_memory_gib": {k: round(v, 2) for k, v in result.peak_memory_gib.items()},
"vsa": result.vsa,
"video_decode_backend": result.video_decode_backend,
"audio_samples": int(result.waveform.shape[-1]),
}, indent=2))
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
"""Compare H3 decoders on saved normalized video rows without rerunning denoise.
The input NPZ must contain a ``video`` array of packed diffusion rows. Geometry
and the DiT manifest must match the generation that produced those rows.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import platform
import resource
import subprocess
import time
from pathlib import Path
import numpy as np
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--latents", type=Path, required=True)
parser.add_argument("--model-root", type=Path, required=True)
parser.add_argument("--mlx-checkpoint", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
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("--backends", nargs="+", choices=("h3-vae", "taeh3"), default=["h3-vae", "taeh3"])
parser.add_argument("--taeh3-checkpoint", type=Path)
parser.add_argument("--taeh3-chunk-size", type=int, default=5)
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
parser.add_argument("--repeats", type=int, default=1)
args = parser.parse_args()
if args.repeats < 1:
parser.error("--repeats must be positive")
if args.output_dir.exists():
parser.error("--output-dir must be a new directory to preserve previous results")
import mlx.core as mx
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline, _cleanup_mlx
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
with np.load(args.latents) as archive:
rows = archive["video"]
if not np.isfinite(rows).all():
raise ValueError("The saved video rows contain non-finite values")
args.output_dir.mkdir(parents=True)
checksum = hashlib.sha256()
with args.latents.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
checksum.update(chunk)
checkpoint = ensure_taeh3_checkpoint(args.taeh3_checkpoint) if "taeh3" in args.backends else None
report = {
"mlx": mx.__version__,
"platform": platform.platform(),
"device": mx.device_info(),
"latents": str(args.latents.resolve()),
"latents_sha256": checksum.hexdigest(),
"geometry": [args.height, args.width, args.num_frames],
"dtype": args.vae_dtype,
"chunk_size": args.taeh3_chunk_size,
"checkpoint_download_excluded": True,
"decoder_loading_included": True,
"trials": [],
}
for repeat in range(args.repeats):
# Reverse each paired trial's order to expose warmup/order effects.
order = args.backends if repeat % 2 == 0 else args.backends[::-1]
for backend in order:
pipeline = MiniMaxH3MLXPipeline(model_root=args.model_root,
mlx_dit_checkpoint=args.mlx_checkpoint,
video_decode_backend=backend,
taeh3_checkpoint=checkpoint if backend == "taeh3" else None,
taeh3_chunk_size=args.taeh3_chunk_size,
vae_dtype=args.vae_dtype)
_cleanup_mlx()
mx.reset_peak_memory()
before = subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip()
started = time.perf_counter()
frames = pipeline.decode_video(rows, height=args.height, width=args.width, num_frames=args.num_frames)
elapsed = time.perf_counter() - started
trial = {
"repeat": repeat,
"backend": backend,
"decode_s": elapsed,
"shape": list(frames.shape),
"peak_active_gib": mx.get_peak_memory() / 2**30,
"process_lifetime_rss_peak_gib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**30,
"swap_before": before,
"swap_after": subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip(),
}
if repeat == 0:
np.save(args.output_dir / f"{backend}_frames.npy", frames)
report["trials"].append(trial)
(args.output_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(trial), flush=True)
del frames, pipeline
_cleanup_mlx()
if __name__ == "__main__":
main()
+28 -10
View File
@@ -2,12 +2,16 @@
The REST serving engine is model-agnostic. Any model supported by
`VideoGenerator` can use the same `/v1/models`, `/v1/videos`, and `/v1/images`
surface; the two configs here are FastH3 validation profiles.
surface. The two FastH3 configs here are source-backed examples, not recorded
serving benchmarks. Both configure four CUDA GPUs without a GPU model or VRAM claim.
Use the [H3 server cookbook](https://haoailab.com/FastVideo/cookbook/openai-api/)
for the guided install, server, and client workflow.
Launch the full FastH3 checkpoint:
```bash
fastvideo serve --config examples/serving/openai_fasth3.yaml
fastvideo serve --config examples/serving/openai_fasth3.yaml --server.host 127.0.0.1
```
Launch the dense FastH3 LoRA on the base MiniMax-H3 checkpoint:
@@ -18,6 +22,7 @@ adapter_path="$(hf download \
dense-datafree/adapter_model.safetensors)"
fastvideo serve --config examples/serving/openai_fasth3_lora.yaml \
--server.host 127.0.0.1 \
--generator.pipeline.components.lora_path "$adapter_path"
```
@@ -28,16 +33,29 @@ selector, but its name, path, and scale must match that startup adapter. A VSA
adapter also needs `attention_backend: VIDEO_SPARSE_ATTN_H3`, `VSA_sparsity`,
and `VSA_tile_size` like the full-checkpoint config.
Submit and poll an asynchronous job:
After startup, open `http://127.0.0.1:8000/playground/` to generate and review
H3 videos in your browser. CUDA prompts reuse the loaded model. The playground
and API clients share the same server; both can run locally.
For native Apple Silicon MLX, use
`python -m fastvideo.entrypoints.openai.mlx_server --config examples/serving/mlx_fasth3.yaml`
after preparing the weights and editing their paths in that configuration.
The same playground and clients work with MLX. Its pipeline still loads and
releases components between phases to limit unified-memory use. See the
cookbook for setup and the supported text-to-video/audio request fields.
Or submit, poll, and download with the OpenAI Python client:
```bash
job_id="$(curl -sS http://localhost:8000/v1/videos \
-H 'content-type: application/json' \
-d '{"model":"fasth3","prompt":"A fox runs through fresh snow."}' \
| jq -r .id)"
curl -sS "http://localhost:8000/v1/videos/$job_id"
curl -o result.mp4 "http://localhost:8000/v1/videos/$job_id/content"
python -m pip install openai==3.6.0
python examples/serving/clients/video.py
```
The `clients/` directory also contains cURL and OpenAI JavaScript examples.
They default to model `fasth3`; set `FASTVIDEO_MODEL` to the advertised alias
when using the LoRA config. Set `FASTVIDEO_BASE_URL` for another endpoint.
These clients call your FastVideo server, not OpenAI's cloud. The SDK key
`local` is a placeholder, not authentication. Keep the server on loopback or
use an authenticated proxy for remote access.
For a blocking call, `POST /v1/videos/sync` returns the MP4 body directly.
+50
View File
@@ -0,0 +1,50 @@
{
"name": "fastvideo-openai-client-example",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fastvideo-openai-client-example",
"dependencies": {
"openai": "7.8.0"
}
},
"node_modules/openai": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-7.8.0.tgz",
"integrity": "sha512-/2g9JzdnXNcjX1W/UlSNu+OdSFDAaAVt0n9Onom0kPenH54o59G2WrX/xjTnr26UHNSh6hxcAf58doGYRme2rw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
"@smithy/hash-node": ">=4.3.0 <5",
"@smithy/signature-v4": ">=5.4.0 <6",
"undici": ">=5 <9",
"ws": "^8.21.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-provider-node": {
"optional": true
},
"@smithy/hash-node": {
"optional": true
},
"@smithy/signature-v4": {
"optional": true
},
"undici": {
"optional": true
},
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "fastvideo-openai-client-example",
"private": true,
"type": "module",
"scripts": {
"generate": "node video.mjs"
},
"dependencies": {
"openai": "7.8.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
// Run in Node.js 22+ or your webapp's backend, not in a browser with a private key.
import OpenAI from "openai";
import { writeFile } from "node:fs/promises";
const client = new OpenAI({
baseURL: process.env.FASTVIDEO_BASE_URL || "http://127.0.0.1:8000/v1",
apiKey: process.env.FASTVIDEO_API_KEY || "local",
timeout: 60_000,
maxRetries: 0,
});
let video = await client.videos.create({
model: process.env.FASTVIDEO_MODEL || "fasth3",
prompt: "A fox runs through fresh snow.",
});
console.log(`Submitted ${video.id}`);
// The deadline limits polling, not GPU execution. Keep the ID to resume later.
const deadline = performance.now() + 1_800_000;
while (video.status === "queued" || video.status === "in_progress") {
let remaining = deadline - performance.now();
if (remaining <= 0) throw new Error(`Polling timed out; job ${video.id} may still be running`);
await new Promise((resolve) => setTimeout(resolve, Math.min(2000, remaining)));
remaining = deadline - performance.now();
if (remaining <= 0) throw new Error(`Polling timed out; job ${video.id} may still be running`);
video = await client.videos.retrieve(video.id, { timeout: Math.min(60_000, remaining) });
}
if (video.status !== "completed") {
throw new Error(`Video ${video.id} failed: ${video.error?.message || video.status}`);
}
const content = await client.videos.downloadContent(video.id);
const output = `${video.id}.mp4`;
await writeFile(output, Buffer.from(await content.arrayBuffer()));
console.log(`Saved ${output}`);
+52
View File
@@ -0,0 +1,52 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate with a local FastVideo server through the OpenAI Python client.
Install the tested client with: python -m pip install openai==3.6.0
Start the server first. No OpenAI account or cloud API key is needed.
"""
import os
import time
from pathlib import Path
from openai import OpenAI
def main() -> None:
with OpenAI(
base_url=os.environ.get("FASTVIDEO_BASE_URL", "http://127.0.0.1:8000/v1"),
api_key=os.environ.get("FASTVIDEO_API_KEY", "local"),
timeout=60.0,
max_retries=0,
) as client:
video = client.videos.create(
model=os.environ.get("FASTVIDEO_MODEL", "fasth3"),
prompt="A fox runs through fresh snow.",
)
print(f"Submitted {video.id}")
# This deadline limits polling, not GPU execution. Increase it for
# longer jobs. On timeout, use the printed ID to retrieve the job.
deadline = time.monotonic() + 1800
while video.status in {"queued", "in_progress"}:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Polling timed out; job {video.id} may still be running")
time.sleep(min(2, remaining))
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Polling timed out; job {video.id} may still be running")
video = client.videos.retrieve(video.id, timeout=min(60, remaining))
if video.status != "completed":
message = video.error.message if video.error else video.status
raise RuntimeError(f"Video {video.id} failed: {message}")
output = Path(f"{video.id}.mp4")
content = client.videos.download_content(video.id)
content.write_to_file(output)
print(f"Saved {output}")
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Requires curl and jq. Run after starting the FastVideo server.
set -euo pipefail
base_url="${FASTVIDEO_BASE_URL:-http://127.0.0.1:8000/v1}"
api_key="${FASTVIDEO_API_KEY:-local}"
model="${FASTVIDEO_MODEL:-fasth3}"
payload=$(jq -n --arg model "$model" \
'{model: $model, prompt: "A fox runs through fresh snow."}')
job=$(curl --fail-with-body --silent --show-error --max-time 60 \
"$base_url/videos" \
-H "Authorization: Bearer $api_key" \
-H 'Content-Type: application/json' \
-d "$payload")
job_id=$(jq -er '.id' <<< "$job")
printf 'Submitted %s\n' "$job_id"
deadline=$((SECONDS + 1800))
while true; do
status=$(jq -er '.status' <<< "$job")
case "$status" in
completed) break ;;
failed) jq -r '.error.message' <<< "$job" >&2; exit 1 ;;
queued|in_progress) ;;
*) printf 'Unexpected job status: %s\n' "$status" >&2; exit 1 ;;
esac
remaining=$((deadline - SECONDS))
if (( remaining <= 0 )); then
printf 'Polling timed out; job %s may still be running\n' "$job_id" >&2
exit 1
fi
if (( remaining > 2 )); then sleep 2; else sleep "$remaining"; fi
remaining=$((deadline - SECONDS))
if (( remaining <= 0 )); then continue; fi
if (( remaining > 60 )); then remaining=60; fi
job=$(curl --fail-with-body --silent --show-error --max-time "$remaining" \
-H "Authorization: Bearer $api_key" "$base_url/videos/$job_id")
done
curl --fail-with-body --silent --show-error --max-time 300 \
-H "Authorization: Bearer $api_key" \
"$base_url/videos/$job_id/content" --output "$job_id.mp4"
printf 'Saved %s.mp4\n' "$job_id"
+23
View File
@@ -0,0 +1,23 @@
# Native MLX H3 server. Run from the repository root after downloading and converting weights.
runtime: mlx
generator:
model_path: FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2
model_root: ./FastH3-Preview-v0.2
mlx_checkpoint: ./FastH3-MLX/int6
prompt_cache_dir: ./outputs/h3_prompt_cache
vae_dtype: fp32
server:
host: 127.0.0.1
port: 8000
served_model_name: fasth3
output_dir: outputs/mlx_fasth3
default_request:
sampling:
height: 480
width: 832
num_frames: 124
fps: 24
# HTTP convention: five sigma points, four DiT forwards. Native MLX uses num_steps=4.
num_inference_steps: 5
guidance_scale: 1.0
seed: 2026
+23
View File
@@ -0,0 +1,23 @@
# Native MLX FastMetal 14B server. Run from the repository root after
# downloading FastVideo/FastMetal-14B-QAD (it ships a pre-packed MLX DiT,
# so model_root and mlx_checkpoint are the same directory).
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_14b.yaml
runtime: mlx
generator:
model_path: FastVideo/FastMetal-14B-QAD
model_root: ./FastMetal-14B-QAD
mlx_checkpoint: ./FastMetal-14B-QAD
server:
host: 127.0.0.1
port: 8000
served_model_name: fastwan21-14b-mlx
output_dir: outputs/mlx_wan21_14b
default_request:
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
num_inference_steps: 3
guidance_scale: 1.0
seed: 1024
+23
View File
@@ -0,0 +1,23 @@
# Native MLX FastMetal 1.3B server. Run from the repository root after
# downloading FastVideo/FastMetal-1.3B-QAD (it ships a pre-packed MLX DiT,
# so model_root and mlx_checkpoint are the same directory).
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_1_3b.yaml
runtime: mlx
generator:
model_path: FastVideo/FastMetal-1.3B-QAD
model_root: ./FastMetal-1.3B-QAD
mlx_checkpoint: ./FastMetal-1.3B-QAD
server:
host: 127.0.0.1
port: 8000
served_model_name: fastwan21-1.3b-mlx
output_dir: outputs/mlx_wan21_1_3b
default_request:
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
num_inference_steps: 3
guidance_scale: 1.0
seed: 1024
+23
View File
@@ -0,0 +1,23 @@
# Native MLX FastMetal 5B (Wan2.2-TI2V) server. Run from the repository root
# after downloading FastVideo/FastMetal-5B-QAD (it ships a pre-packed MLX DiT,
# so model_root and mlx_checkpoint are the same directory).
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan22_5b.yaml
runtime: mlx
generator:
model_path: FastVideo/FastMetal-5B-QAD
model_root: ./FastMetal-5B-QAD
mlx_checkpoint: ./FastMetal-5B-QAD
server:
host: 127.0.0.1
port: 8000
served_model_name: fastwan22-5b-mlx
output_dir: outputs/mlx_wan22_5b
default_request:
sampling:
height: 704
width: 1280
num_frames: 81
fps: 24
num_inference_steps: 3
guidance_scale: 1.0
seed: 1234
+30 -8
View File
@@ -2,7 +2,7 @@
# (https://github.com/sgl-project/sglang/blob/main/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py)
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
import os
import uvicorn
@@ -18,7 +18,8 @@ from fastvideo.entrypoints.openai.state import (
clear_state,
set_state,
)
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine, ServingGenerator
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
from fastvideo.entrypoints.video_generator import VideoGenerator
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
@@ -60,10 +61,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
served_model_name: str | None = app.state.served_model_name
default_request: GenerationRequest | None = getattr(app.state, "default_request", None)
logger.info("Loading model from %s ...", args.model_path)
generator = VideoGenerator.from_fastvideo_args(args)
serving_engine = OpenAIServingEngine(generator)
logger.info("Model loaded successfully.")
logger.info("Initializing %s generation runtime for %s ...", app.state.runtime, args.model_path)
# A non-CUDA runtime (e.g. MLX) supplies its own generator_factory instead
# of the default VideoGenerator/Executor path -- see MLXWanGenerator.
factory = app.state.generator_factory
generator = factory() if factory is not None else VideoGenerator.from_fastvideo_args(args)
serving_engine = OpenAIServingEngine(generator, app.state.video_request_validator)
logger.info("Generation runtime ready.")
set_state(
generator,
@@ -91,8 +95,18 @@ def create_app(
output_dir: str = DEFAULT_OUTPUT_DIR,
default_request: GenerationRequest | None = None,
served_model_name: str | None = None,
*,
generator_factory: Callable[[], ServingGenerator] | None = None,
video_request_validator: Callable[[VideoGenerationRequest], None] | None = None,
runtime: str = "cuda",
) -> FastAPI:
"""Build the FastAPI application with all routers mounted"""
"""Build the FastAPI application with all routers mounted.
``generator_factory``/``video_request_validator``/``runtime`` let a
non-CUDA backend (MLX) plug in its own generator and request rules
without a separate app-building path. Defaults reproduce the original
CUDA-only behavior exactly.
"""
app = FastAPI(
title="FastVideo OpenAI-Compatible API",
@@ -103,6 +117,9 @@ def create_app(
app.state.output_dir = output_dir
app.state.default_request = default_request
app.state.served_model_name = served_model_name
app.state.generator_factory = generator_factory
app.state.video_request_validator = video_request_validator
app.state.runtime = runtime
app.add_middleware(
CORSMiddleware,
@@ -144,11 +161,16 @@ def create_app(
# Import and mount routers
from fastvideo.entrypoints.openai.common_api import router as common_router
from fastvideo.entrypoints.openai.image_api import router as image_router
from fastvideo.entrypoints.openai.playground import router as playground_router
from fastvideo.entrypoints.openai.video_api import router as video_router
app.include_router(common_router)
app.include_router(video_router)
app.include_router(image_router)
# The MLX runtime only wires video-with-audio generation (see
# fastvideo/mlx_runtime/); image generation has no MLX backend yet.
if runtime != "mlx":
app.include_router(image_router)
app.include_router(playground_router)
@app.get("/health")
async def health():
+197
View File
@@ -0,0 +1,197 @@
# SPDX-License-Identifier: Apache-2.0
"""Serve native FastH3 MLX through the shared video-job API and playground."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import platform
import shutil
import time
from types import SimpleNamespace
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
import uvicorn
import yaml
from fastvideo.api.compat import explicit_request_updates, normalize_generation_request
from fastvideo.api.schema import GenerationRequest
from fastvideo.entrypoints.openai.api_server import create_app
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
MODEL: Literal["FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"] = "FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"
class MLXGeneratorConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
model_path: Literal["FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"] = MODEL
model_root: str
mlx_checkpoint: str
prompt_cache_dir: str = "outputs/h3_prompt_cache"
vae_dtype: Literal["fp32", "fp16", "bf16"] = "fp32"
class MLXServerConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
host: str = "127.0.0.1"
port: int = Field(default=8000, ge=1, le=65535)
output_dir: str = "outputs/mlx_fasth3"
served_model_name: str = Field(default="fasth3", min_length=1)
class MLXServeConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
runtime: Literal["mlx"]
generator: MLXGeneratorConfig
server: MLXServerConfig = Field(default_factory=MLXServerConfig)
default_request: dict[str, Any]
def validate_mlx_video_request(request: VideoGenerationRequest) -> None:
"""Reject unsupported inputs before fetching media or creating a job."""
allowed = {
"model",
"prompt",
"seed",
"size",
"width",
"height",
"fps",
"num_frames",
"seconds",
"video_params",
"task",
"guidance_scale",
"num_inference_steps",
"negative_prompt",
}
unsupported = request.model_fields_set - allowed
if unsupported:
raise ValueError("H3 MLX serving does not support: " + ", ".join(sorted(unsupported)))
if request.task not in (None, "t2va"):
raise ValueError("H3 MLX serving supports task=t2va only.")
if request.guidance_scale not in (None, 1.0):
raise ValueError("FastH3 MLX requires guidance_scale=1.")
if request.negative_prompt not in (None, ""):
raise ValueError("FastH3 MLX does not use a negative prompt.")
if request.num_inference_steps not in (None, 5):
raise ValueError(
"FastH3 MLX serving uses five sigma points and four DiT forwards; num_inference_steps must be 5.")
if request.seed is not None and not 0 <= request.seed <= 2**32 - 1:
raise ValueError("H3 MLX seed must be between 0 and 4294967295.")
class MLXH3Generator:
"""Keep one pipeline on one MLX thread; preserve its phase-memory policy."""
def __init__(self, config: MLXGeneratorConfig) -> None:
self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="h3-mlx")
try:
self._pipeline = self._worker.submit(self._load, config).result()
except BaseException:
self._worker.shutdown(wait=True)
raise
@staticmethod
def _load(config: MLXGeneratorConfig):
if platform.system() != "Darwin" or platform.machine() != "arm64":
raise RuntimeError("H3 MLX serving requires an Apple Silicon Mac.")
if shutil.which("ffmpeg") is None:
raise RuntimeError("Install ffmpeg before starting the H3 MLX server.")
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline
return MiniMaxH3MLXPipeline(
model_root=Path(config.model_root).expanduser(),
mlx_dit_checkpoint=Path(config.mlx_checkpoint).expanduser(),
prompt_cache_dir=Path(config.prompt_cache_dir).expanduser(),
vae_dtype=config.vae_dtype,
)
def generate(self, request: GenerationRequest) -> dict[str, Any]:
return self._worker.submit(self._generate, request).result()
def _generate(self, request: GenerationRequest) -> dict[str, Any]:
started = time.perf_counter()
result = self._pipeline.generate(
request.prompt,
output_path=request.output.output_path,
width=request.sampling.width,
height=request.sampling.height,
num_frames=request.sampling.num_frames,
seed=request.sampling.seed,
num_steps=4,
)
# Do not retain decoded frames/waveforms or label phase peaks as total RAM.
return {"video_path": str(result.video_path), "generation_time": time.perf_counter() - started}
def shutdown(self) -> None:
def release():
self._pipeline = None
from fastvideo.mlx_runtime.minimax_h3_pipeline import _cleanup_mlx
_cleanup_mlx()
try:
self._worker.submit(release).result()
finally:
self._worker.shutdown(wait=True)
def load_config(path: str) -> MLXServeConfig:
with open(path, encoding="utf-8") as source:
return MLXServeConfig.model_validate(yaml.safe_load(source))
def create_mlx_app(config: MLXServeConfig):
request = normalize_generation_request(config.default_request)
explicit = explicit_request_updates(request)
supported = {
"width", "height", "num_frames", "fps", "seed", "num_inference_steps", "guidance_scale", "negative_prompt"
}
if set(explicit) - supported:
raise ValueError("MLX default_request contains unsupported fields: " +
", ".join(sorted(set(explicit) - supported)))
required = {"width", "height", "num_frames", "fps", "seed", "num_inference_steps", "guidance_scale"}
if required - set(explicit):
raise ValueError("MLX default_request must set: " + ", ".join(sorted(required - set(explicit))))
validate_mlx_video_request(VideoGenerationRequest(prompt="validate config", **explicit))
# Transport admission uses the registered H3 family, not CUDA engine options.
args = SimpleNamespace(model_path=MODEL,
lora_path=None,
lora_nickname="default",
lora_strength=1.0,
override_pipeline_cls_name=None)
from fastvideo.entrypoints.openai.request_adapter import build_generation_request
build_generation_request("config-check",
VideoGenerationRequest(prompt="validate config"),
args,
served_model_name=config.server.served_model_name,
output_dir=config.server.output_dir,
default_request=request)
return create_app(
args,
config.server.output_dir,
request,
config.server.served_model_name,
generator_factory=lambda: MLXH3Generator(config.generator),
video_request_validator=validate_mlx_video_request,
runtime="mlx",
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config",
required=True,
help="H3 MLX serving YAML; paths are relative to the working directory")
args = parser.parse_args()
config = load_config(args.config)
uvicorn.run(create_mlx_app(config), host=config.server.host, port=config.server.port)
if __name__ == "__main__":
main()
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
"""Serve native FastMetal (Wan2.1) MLX through the shared video-job API and playground."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import platform
import shutil
import time
from types import SimpleNamespace
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
import uvicorn
import yaml
from fastvideo.api.compat import explicit_request_updates, normalize_generation_request
from fastvideo.api.schema import GenerationRequest
from fastvideo.entrypoints.openai.api_server import create_app
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
# The DMD-distilled step ladder the validated recipes use (fixed count, same
# reason H3 MLX serving pins num_inference_steps to its own ladder size).
_DMD_STEP_COUNT = 3
# Maps a served model id to the pipeline class that generates it: 1.3B/14B
# share Wan2.1's architecture (MLXWanPipeline), 5B is Wan2.2-TI2V instead
# (MLXWan22Pipeline, 48-channel latents, a different DiT and sampler).
_PIPELINE_CLASS_NAMES = {
"FastVideo/FastMetal-1.3B-QAD": "MLXWanPipeline",
"FastVideo/FastMetal-14B-QAD": "MLXWanPipeline",
"FastVideo/FastMetal-5B-QAD": "MLXWan22Pipeline",
}
class MLXWanGeneratorConfig(BaseModel):
"""Where the two FastMetal checkpoint halves live on disk."""
model_config = ConfigDict(extra="forbid")
model_path: Literal["FastVideo/FastMetal-1.3B-QAD", "FastVideo/FastMetal-14B-QAD", "FastVideo/FastMetal-5B-QAD"]
model_root: str
mlx_checkpoint: str
class MLXWanServerConfig(BaseModel):
"""Host/port/output shape for an MLX serve YAML's ``server:`` block.
Generic across MLX-served models -- if a second MLX server config needs
the same shape, promote this (and its H3 counterpart) to one shared
module instead of a third copy.
"""
model_config = ConfigDict(extra="forbid")
host: str = "127.0.0.1"
port: int = Field(default=8000, ge=1, le=65535)
output_dir: str = "outputs/mlx_wan"
served_model_name: str = Field(default="fastwan", min_length=1)
class MLXWanServeConfig(BaseModel):
"""Top-level ``mlx_wan_*.yaml`` shape read by --config."""
model_config = ConfigDict(extra="forbid")
runtime: Literal["mlx"]
generator: MLXWanGeneratorConfig
server: MLXWanServerConfig = Field(default_factory=MLXWanServerConfig)
default_request: dict[str, Any]
def validate_wan_video_request(request: VideoGenerationRequest) -> None:
"""Reject unsupported inputs before fetching media or creating a job."""
allowed = {
"model",
"prompt",
"seed",
"size",
"width",
"height",
"fps",
"num_frames",
"seconds",
"video_params",
"task",
"guidance_scale",
"num_inference_steps",
}
unsupported = request.model_fields_set - allowed
if unsupported:
raise ValueError("Wan MLX serving does not support: " + ", ".join(sorted(unsupported)))
if request.task not in (None, "t2v"):
raise ValueError("Wan MLX serving supports task=t2v only.")
if request.guidance_scale not in (None, 1.0):
raise ValueError("FastMetal MLX is DMD-distilled and requires guidance_scale=1.")
if request.num_inference_steps not in (None, _DMD_STEP_COUNT):
raise ValueError(f"Wan MLX serving uses a fixed {_DMD_STEP_COUNT}-step DMD ladder; "
f"num_inference_steps must be {_DMD_STEP_COUNT}.")
if request.seed is not None and not 0 <= request.seed <= 2**32 - 1:
raise ValueError("Wan MLX seed must be between 0 and 4294967295.")
class MLXWanGenerator:
"""Keep one FastMetal pipeline on one MLX thread across requests."""
def __init__(self, config: MLXWanGeneratorConfig) -> None:
self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wan-mlx")
try:
self._pipeline = self._worker.submit(self._load, config).result()
except BaseException:
self._worker.shutdown(wait=True)
raise
@staticmethod
def _load(config: MLXWanGeneratorConfig):
"""Load the pipeline; must run on the MLX worker thread."""
if platform.system() != "Darwin" or platform.machine() != "arm64":
raise RuntimeError("Wan MLX serving requires an Apple Silicon Mac.")
if shutil.which("ffmpeg") is None:
raise RuntimeError("Install ffmpeg before starting the Wan MLX server.")
import fastvideo.mlx_runtime.wan_pipeline as wan_pipeline_module
pipeline_cls = getattr(wan_pipeline_module, _PIPELINE_CLASS_NAMES[config.model_path])
return pipeline_cls(
model_root=Path(config.model_root).expanduser(),
mlx_checkpoint=Path(config.mlx_checkpoint).expanduser(),
)
def generate(self, request: GenerationRequest) -> dict[str, Any]:
"""Run one generation on the MLX worker thread; block until it finishes."""
return self._worker.submit(self._generate, request).result()
def _generate(self, request: GenerationRequest) -> dict[str, Any]:
"""The actual pipeline call; must run on the MLX worker thread."""
started = time.perf_counter()
result = self._pipeline.generate(
request.prompt,
output_path=request.output.output_path,
width=request.sampling.width,
height=request.sampling.height,
num_frames=request.sampling.num_frames,
seed=request.sampling.seed,
fps=request.sampling.fps,
)
return {"video_path": str(result.video_path), "generation_time": time.perf_counter() - started}
def shutdown(self) -> None:
"""Release the pipeline and stop the MLX worker thread."""
def release():
self._pipeline = None
from fastvideo.mlx_runtime.memory import cleanup_mlx
cleanup_mlx()
try:
self._worker.submit(release).result()
finally:
self._worker.shutdown(wait=True)
def load_config(path: str) -> MLXWanServeConfig:
"""Parse a Wan MLX serve YAML into its typed config."""
with open(path, encoding="utf-8") as source:
return MLXWanServeConfig.model_validate(yaml.safe_load(source))
def create_mlx_wan_app(config: MLXWanServeConfig):
"""Build the FastAPI app for a validated Wan MLX serve config."""
request = normalize_generation_request(config.default_request)
explicit = explicit_request_updates(request)
supported = {"width", "height", "num_frames", "fps", "seed", "num_inference_steps", "guidance_scale"}
if set(explicit) - supported:
raise ValueError("Wan MLX default_request contains unsupported fields: " +
", ".join(sorted(set(explicit) - supported)))
required = {"width", "height", "num_frames", "fps"}
if required - set(explicit):
raise ValueError("Wan MLX default_request must set: " + ", ".join(sorted(required - set(explicit))))
validate_wan_video_request(VideoGenerationRequest(prompt="validate config", **explicit))
# Transport admission uses the registered Wan family, not CUDA engine options.
args = SimpleNamespace(model_path=config.generator.model_path,
lora_path=None,
lora_nickname="default",
lora_strength=1.0,
override_pipeline_cls_name=None)
from fastvideo.entrypoints.openai.request_adapter import build_generation_request
build_generation_request("config-check",
VideoGenerationRequest(prompt="validate config"),
args,
served_model_name=config.server.served_model_name,
output_dir=config.server.output_dir,
default_request=request)
return create_app(
args,
config.server.output_dir,
request,
config.server.served_model_name,
generator_factory=lambda: MLXWanGenerator(config.generator),
video_request_validator=validate_wan_video_request,
runtime="mlx",
)
def main() -> None:
"""CLI entrypoint: python -m fastvideo.entrypoints.openai.mlx_wan_server --config ..."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config",
required=True,
help="Wan MLX serving YAML; paths are relative to the working directory")
args = parser.parse_args()
config = load_config(args.config)
uvicorn.run(create_mlx_wan_app(config), host=config.server.host, port=config.server.port)
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: Apache-2.0
"""Same-origin prompt UI for the H3 text-to-video/audio server."""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from fastvideo.api.compat import explicit_request_updates
from fastvideo.entrypoints.openai.state import get_default_request, get_served_model_name, get_server_args
from fastvideo.registry import get_preset_selection
ASSETS = Path(__file__).with_name("static")
HEADERS = {
"Cache-Control":
"no-store",
"X-Content-Type-Options":
"nosniff",
"Content-Security-Policy":
("default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
"media-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"),
}
def require_h3() -> None:
args = get_server_args()
_, family = get_preset_selection(args.model_path)
override = getattr(args, "override_pipeline_cls_name", None)
if family != "minimax_h3" or (override and override != "MiniMaxH3ModularPipeline"):
raise HTTPException(status_code=404, detail="The playground requires an H3 text-to-video/audio server.")
router = APIRouter(prefix="/playground", dependencies=[Depends(require_h3)], include_in_schema=False)
@router.get("/")
async def playground() -> FileResponse:
return FileResponse(ASSETS / "playground.html", headers=HEADERS)
@router.get("/config")
async def playground_config(raw_request: Request) -> dict:
request = get_default_request()
# Report operator defaults only; do not guess model presets or hardware.
sampling = explicit_request_updates(request) if request is not None else {}
fields = ("width", "height", "num_frames", "fps", "seed")
return {
"model": get_served_model_name(),
"runtime": raw_request.app.state.runtime,
"defaults": {
field: sampling.get(field)
for field in fields
},
}
@router.get("/{asset}")
async def playground_asset(asset: str) -> FileResponse:
if asset not in {"playground.css", "playground.js"}:
raise HTTPException(status_code=404, detail="Playground asset not found.")
return FileResponse(ASSETS / asset, headers=HEADERS)
+27 -5
View File
@@ -5,14 +5,28 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
from typing import Any, Protocol, TypeVar
from fastvideo.api.schema import GenerationRequest
from fastvideo.entrypoints.video_generator import VideoGenerator
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
_T = TypeVar("_T")
class ServingGenerator(Protocol):
"""The minimal shape OpenAIServingEngine needs from a generator.
VideoGenerator (CUDA/mp/ray) satisfies this structurally already; an MLX
generator (e.g. MLXWanGenerator) implements just these two methods.
"""
def generate(self, request: GenerationRequest) -> Any:
...
def shutdown(self) -> None:
...
class OpenAIServingEngine:
"""Own generator lifecycle and serialize access to its mutable pipeline.
@@ -24,16 +38,24 @@ class OpenAIServingEngine:
the lock without changing the transport contract.
"""
def __init__(self, generator: VideoGenerator) -> None:
def __init__(self,
generator: ServingGenerator,
video_request_validator: Callable[[VideoGenerationRequest], None] | None = None) -> None:
self._generator = generator
self._video_request_validator = video_request_validator
self._generation_lock = asyncio.Lock()
self._closed = False
self._unhealthy_reason: str | None = None
@property
def generator(self) -> VideoGenerator:
def generator(self) -> ServingGenerator:
return self._generator
def validate_video_request(self, request: VideoGenerationRequest) -> None:
"""Runtime-specific request checks (e.g. MLX's supported-field allowlist)."""
if self._video_request_validator is not None:
self._video_request_validator(request)
@property
def closed(self) -> bool:
return self._closed
@@ -127,4 +149,4 @@ class OpenAIServingEngine:
await asyncio.to_thread(self._generator.shutdown)
__all__ = ["OpenAIServingEngine"]
__all__ = ["OpenAIServingEngine", "ServingGenerator"]
+4 -5
View File
@@ -11,13 +11,12 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastvideo.api.schema import GenerationRequest
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
from fastvideo.entrypoints.video_generator import VideoGenerator
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine, ServingGenerator
from fastvideo.fastvideo_args import FastVideoArgs
DEFAULT_OUTPUT_DIR = "outputs"
_generator: VideoGenerator | None = None
_generator: ServingGenerator | None = None
_serving_engine: OpenAIServingEngine | None = None
_fastvideo_args: FastVideoArgs | None = None
_output_dir: str = DEFAULT_OUTPUT_DIR
@@ -25,7 +24,7 @@ _served_model_name: str | None = None
_default_request: GenerationRequest | None = None
def get_generator() -> VideoGenerator:
def get_generator() -> ServingGenerator:
"""Return the global VideoGenerator instance (set during startup)."""
assert _generator is not None, "Server not initialized — generator is None"
return _generator
@@ -62,7 +61,7 @@ def get_default_request() -> GenerationRequest | None:
def set_state(
generator: VideoGenerator,
generator: ServingGenerator,
serving_engine: OpenAIServingEngine,
fastvideo_args: FastVideoArgs,
output_dir: str,
@@ -0,0 +1,105 @@
:root {
color-scheme: light dark;
--page: #fafafa;
--surface: #fff;
--inset: #f4f4f5;
--text: #202026;
--muted: #60606b;
--line: #dedee5;
--accent: #4f46e5;
--accent-soft: #eeecff;
--error: #a42030;
font: 16px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--page);
color: var(--text);
}
@media (prefers-color-scheme: dark) {
:root {
--page: #1e1e25;
--surface: #25252e;
--inset: #1a1a21;
--text: #f1f1f5;
--muted: #b2b2c0;
--line: #41414e;
--accent: #b0a9ff;
--accent-soft: #343044;
--error: #ffabb6;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; }
a { color: var(--accent); text-underline-offset: 3px; }
button, input, textarea { font: inherit; }
button, .button { min-height: 44px; padding: 10px 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--text); cursor: pointer; text-decoration: none; }
button { transition: background-color 150ms ease-out, transform 150ms ease-out; }
button:not(:disabled):active { transform: scale(0.96); }
button:hover:not(:disabled), .button:hover { background: var(--inset); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.skip-link { position: absolute; inset-block-start: -80px; padding: 12px; background: var(--surface); z-index: 1; }
.skip-link:focus { inset-block-start: 0; }
.site-header { max-width: 1240px; margin-inline: auto; padding: 24px 32px; display: flex; justify-content: space-between; align-items: center; gap: 16px; border-block-end: 1px solid var(--line); font-size: 14px; }
.wordmark { font-size: 19px; font-weight: 700; color: var(--text); text-decoration: none; }
.wordmark span { margin-inline-start: 8px; font-size: 14px; font-weight: 400; color: var(--muted); }
main { max-width: 1240px; padding: 44px 32px 24px; margin-inline: auto; }
h1, h2, h3, p { margin-block: 0; }
h1 { font-size: clamp(28px, 3vw, 38px); line-height: 1.2; font-weight: 600; letter-spacing: -0.035em; margin-block: 8px 14px; text-wrap: balance; }
h2 { font-size: 19px; font-weight: 600; }
h3 { font-size: 16px; font-weight: 600; }
.eyebrow { font-size: 12px; font-weight: 600; color: var(--accent); letter-spacing: 0.09em; text-transform: uppercase; }
.intro > p:last-child { max-width: 72ch; color: var(--muted); }
.connection-bar { display: flex; flex-wrap: wrap; gap: 8px 24px; justify-content: space-between; margin-block: 28px 20px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; }
#connection { font-weight: 600; }
#model { color: var(--muted); overflow-wrap: anywhere; }
.workspace { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 24px; align-items: start; }
.composer, .output { min-width: 0; padding: 24px; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
.composer h2 { margin-block-end: 24px; }
label { display: block; font-size: 14px; font-weight: 600; }
.hint, .settings { font-size: 13px; color: var(--muted); }
.hint { margin-block: 6px 12px; }
textarea, input { width: 100%; padding: 12px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--inset); }
textarea { resize: vertical; min-height: 172px; }
textarea::placeholder, input::placeholder { color: var(--muted); opacity: 1; }
.seed-row { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; align-items: center; margin-block: 20px; }
.seed-row .hint { margin-block-end: 0; }
.optional { font-weight: 400; color: var(--muted); }
.settings { padding-block: 14px; border-block: 1px solid var(--line); margin-block-end: 20px; }
button.primary { display: flex; justify-content: space-between; align-items: center; width: 100%; background: var(--accent); color: var(--page); border-color: transparent; font-weight: 600; }
button.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.1); }
.request-details { margin-block-start: 20px; padding-block-start: 20px; border-block-start: 1px solid var(--line); }
summary { cursor: pointer; min-height: 32px; font-size: 14px; }
pre { padding: 16px; font-size: 12px; background: var(--inset); border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
#copy-status { display: block; font-size: 13px; margin-block-start: 8px; }
.section-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-block-end: 18px; }
.state { padding: 4px 10px; border-radius: 6px; background: var(--inset); font-size: 12px; }
.state[data-status="in_progress"], .state[data-status="queued"] { background: var(--accent-soft); color: var(--accent); }
.preview { min-height: 240px; aspect-ratio: 7 / 4; display: grid; place-items: center; overflow: hidden; border-radius: 8px; background: var(--inset); border: 1px solid var(--line); text-align: center; }
#empty-preview { padding: 24px; }
.play-mark { display: block; font-size: 36px; color: var(--muted); margin-block-end: 12px; }
#empty-preview p { color: var(--muted); font-size: 13px; margin-block-start: 6px; }
video { width: 100%; max-height: 420px; }
#job-status, .error { margin-block-start: 16px; font-size: 14px; }
.error { color: var(--error); overflow-wrap: anywhere; }
.result-actions { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin-block-start: 16px; font-size: 14px; }
.job-id { color: var(--muted); font: 12px/1.5 ui-monospace, monospace; overflow-wrap: anywhere; margin-block-start: 12px; }
.history { border-block-start: 1px solid var(--line); margin-block-start: 28px; padding-block-start: 20px; }
.history .section-heading { margin-block-end: 0; }
.history button { font-size: 13px; }
#jobs { padding: 0; margin-block: 16px 0; list-style: none; }
#jobs li + li { margin-block-start: 8px; }
#jobs button { width: 100%; text-align: start; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
#jobs button[aria-current="true"] { border-color: var(--accent); background: var(--accent-soft); }
#jobs .job-prompt { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#jobs .job-state { flex-shrink: 0; color: var(--muted); font-size: 12px; }
footer { color: var(--muted); font-size: 12px; max-width: 90ch; padding-block: 28px 8px; }
@media (max-width: 800px) { .workspace { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 480px) {
main { padding: 28px 16px 16px; }
.site-header { padding: 16px; }
.wordmark span { display: block; margin-inline: 0; }
.composer, .output { padding: 18px; }
.seed-row { grid-template-columns: minmax(0, 1fr); }
.preview { min-height: 200px; }
}
@media (prefers-reduced-motion: reduce) { button { transition: none; } button:not(:disabled):active { transform: none; } }
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>H3 playground · FastVideo</title>
<link rel="stylesheet" href="./playground.css">
<script src="./playground.js" defer></script>
</head>
<body>
<a class="skip-link" href="#prompt">Skip to prompt</a>
<header class="site-header">
<a class="wordmark" href="./">FastVideo <span> / H3 playground</span></a>
<a href="/docs">API reference <span aria-hidden="true">↗</span></a>
</header>
<main>
<div class="intro">
<p class="eyebrow">Text to video + audio</p>
<h1>A new prompt. The same running server.</h1>
<p>Generate, watch, and refine. This page and your API clients share one running FastVideo server.</p>
</div>
<div class="connection-bar">
<span id="connection" role="status">Connecting to server…</span>
<span id="model">Reading model…</span>
</div>
<div class="workspace">
<section class="composer" aria-labelledby="compose-heading">
<h2 id="compose-heading">Create a video</h2>
<form id="generate-form">
<label for="prompt">Prompt</label>
<p class="hint" id="prompt-hint">Describe the scene, motion, and sound you want.</p>
<textarea id="prompt" name="prompt" rows="7" required aria-describedby="prompt-hint" placeholder="A fox runs through fresh snow. The camera follows at ground level as snow crunches under its paws."></textarea>
<div class="seed-row">
<div><label for="seed">Seed <span class="optional">optional</span></label><p class="hint" id="seed-hint">Keep it fixed while changing the prompt.</p></div>
<input id="seed" name="seed" type="number" min="0" max="4294967295" step="1" placeholder="Server default" aria-describedby="seed-hint">
</div>
<p class="settings" id="settings">Resolution and sampling come from the server configuration.</p>
<button class="primary" id="generate" type="submit" disabled>Generate video <span aria-hidden="true">↗</span></button>
<p class="hint" id="lifetime">Closing this page does not cancel a job.</p>
</form>
<details class="request-details">
<summary>Use this prompt with cURL</summary>
<p class="hint">This creates the same job from a terminal on this machine. Copying does not submit it.</p>
<pre><code id="curl-command"></code></pre>
<button id="copy-curl" type="button" disabled>Copy cURL</button>
<span id="copy-status" role="status"></span>
</details>
</section>
<section class="output" aria-labelledby="output-heading">
<div class="section-heading"><h2 id="output-heading">Result</h2><span class="state" id="job-state">No job yet</span></div>
<div class="preview">
<div id="empty-preview"><span class="play-mark" aria-hidden="true">▷</span><h3>Your video will appear here</h3><p>Write a prompt, then select Generate video.</p></div>
<video id="video" controls playsinline preload="metadata" aria-label="Generated video" hidden></video>
</div>
<p id="job-status" role="status" aria-live="polite">No generation starts until you submit a prompt.</p>
<p class="error" id="error" role="alert" hidden></p>
<div class="result-actions">
<a class="button" id="download" hidden>Download MP4</a>
<button id="check-status" type="button" hidden>Check status</button>
<a id="job-link" hidden>View job JSON</a>
</div>
<p class="job-id" id="job-id"></p>
<section class="history" aria-labelledby="history-heading">
<div class="section-heading"><h3 id="history-heading">Recent jobs</h3><button id="refresh-jobs" type="button">Refresh jobs</button></div>
<p class="hint">Shared with other clients. Job history clears when the server restarts.</p>
<ul id="jobs"></ul>
<p class="hint" id="history-status" role="status">Loading recent jobs…</p>
</section>
</section>
</div>
<footer>Local development only. FastVideo does not check API keys. Keep the server on loopback or behind an authenticated proxy.</footer>
<noscript>This playground needs JavaScript. Use the cURL examples in the FastVideo cookbook instead.</noscript>
</main>
</body>
</html>
@@ -0,0 +1,213 @@
/* SPDX-License-Identifier: Apache-2.0 */
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const labels = { queued: "Queued", in_progress: "Generating", completed: "Completed", failed: "Failed" };
const active = (job) => ["queued", "in_progress"].includes(job.status);
let model = "";
let readyLabel = "Server ready";
let busy = false;
let currentJob = null;
let pollVersion = 0;
const api = async (path, options = {}) => {
const response = await fetch(path, { ...options, signal: AbortSignal.timeout(15000) });
const body = await response.json();
if (!response.ok) throw new Error(body.error?.message || `Request failed (HTTP ${response.status}).`);
return body;
};
const setBusy = (value) => {
busy = value;
$("generate").disabled = value || !model;
$("generate").textContent = value ? "Waiting for this job…" : "Generate video ↗";
};
const showError = (message = "") => {
$("error").textContent = message;
$("error").hidden = !message;
};
const payload = () => {
const body = { model, prompt: $("prompt").value.trim() };
if ($("seed").value !== "") body.seed = Number($("seed").value);
return body;
};
const updateCurl = () => {
const quote = (text) => "'" + text.replaceAll("'", "'\\''") + "'";
$("curl-command").textContent = `curl --fail-with-body ${quote(location.origin + "/v1/videos")} \\\n -H 'Content-Type: application/json' \\\n --data-raw ${quote(JSON.stringify(payload(), null, 2))}`;
$("copy-curl").disabled = !model || !$("prompt").value.trim() || !$("seed").validity.valid;
$("copy-status").textContent = "";
};
const refreshJobs = async () => {
$("refresh-jobs").disabled = true;
try {
const result = await api("/v1/videos?limit=8&order=desc");
$("jobs").replaceChildren();
result.data.forEach((job) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-current", String(job.id === currentJob?.id));
const prompt = document.createElement("span");
prompt.className = "job-prompt";
prompt.textContent = job.prompt || job.id;
const state = document.createElement("span");
state.className = "job-state";
state.textContent = labels[job.status] || job.status;
button.append(prompt, state);
button.addEventListener("click", () => {
if (busy) {
showError("Wait for this job, or open another playground tab to inspect a different job.");
return;
}
$("prompt").value = job.prompt || "";
// Jobs do not report the seed; do not pair an old seed with this prompt.
$("seed").value = "";
updateCurl();
followJob(job.id);
});
item.append(button);
$("jobs").append(item);
});
$("history-status").textContent = result.data.length ? "Showing the latest jobs from this server." : "No jobs yet. Submit a prompt to start.";
} catch (error) {
$("history-status").textContent = `Could not load jobs. Check the server and select Refresh jobs. ${error.message}`;
} finally {
$("refresh-jobs").disabled = false;
}
};
const renderJob = (job) => {
currentJob = job;
$("job-state").textContent = labels[job.status] || job.status;
$("job-state").dataset.status = job.status;
$("job-id").textContent = `Job ${job.id}`;
const path = `/v1/videos/${encodeURIComponent(job.id)}`;
$("job-link").href = path;
$("job-link").hidden = false;
const complete = job.status === "completed";
$("video").hidden = !complete;
$("empty-preview").hidden = complete;
$("download").hidden = !complete;
if (complete) {
$("video").src = `${path}/content`;
$("download").href = `${path}/content`;
$("download").download = `${job.id}.mp4`;
$("job-status").textContent = "Video ready. Change the prompt and generate again without restarting the server.";
} else {
$("video").pause();
$("video").removeAttribute("src");
$("empty-preview").querySelector("h3").textContent = job.status === "failed" ? "Generation failed" : "Your job is on the server";
$("empty-preview").querySelector("p").textContent = job.status === "failed" ? "Read the error below before trying again." : "You can keep editing the next prompt while you wait.";
$("job-status").textContent = job.status === "queued" ? "Queued. The server runs one generation at a time." : job.status === "failed" ? "The server could not complete this job." : "Generating video and audio. This page checks the job status automatically.";
}
if (job.status === "failed") showError(job.error?.message || "Check the server logs before submitting another job.");
};
const followJob = async (id) => {
const version = ++pollVersion;
const url = new URL(location.href);
url.searchParams.set("job", id);
history.replaceState({}, "", url);
showError();
setBusy(true);
$("check-status").hidden = true;
const deadline = Date.now() + 30 * 60 * 1000;
let restorePrompt = !$("prompt").value.trim();
try {
while (version === pollVersion) {
const job = await api(`/v1/videos/${encodeURIComponent(id)}`);
if (version !== pollVersion) return;
$("connection").textContent = readyLabel;
if (restorePrompt) {
$("prompt").value = job.prompt || "";
updateCurl();
restorePrompt = false;
}
renderJob(job);
if (!active(job)) break;
if (Date.now() >= deadline) throw new Error("Stopped checking after 30 minutes. The job may still be running.");
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} catch (error) {
if (version !== pollVersion) return;
$("connection").textContent = "Job status unavailable · check the server";
showError(`${error.message} Select Check status to reconnect. A connection error does not cancel generation. If the job is missing, the server may have restarted or the job was deleted.`);
$("check-status").hidden = false;
} finally {
if (version === pollVersion) {
setBusy(false);
await refreshJobs();
}
}
};
$("generate-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (busy || !model) return;
if (!$("prompt").value.trim()) {
showError("Write a prompt before generating a video.");
$("prompt").focus();
return;
}
showError();
setBusy(true);
$("job-status").textContent = "Submitting the prompt…";
try {
const job = await api("/v1/videos", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload()),
});
renderJob(job);
await followJob(job.id);
} catch (error) {
showError(`${error.message} Check Recent jobs before submitting again; the server may have received the prompt. Submissions are never retried automatically.`);
$("job-status").textContent = "Could not confirm the submission.";
setBusy(false);
await refreshJobs();
}
});
$("prompt").addEventListener("input", updateCurl);
$("seed").addEventListener("input", updateCurl);
$("refresh-jobs").addEventListener("click", refreshJobs);
$("check-status").addEventListener("click", () => {
const id = new URL(location.href).searchParams.get("job");
if (id) followJob(id);
});
$("video").addEventListener("error", () => {
if (!$("video").hasAttribute("src")) return;
showError("The browser could not play this video. Download the MP4 to check it, or inspect the server logs.");
});
$("copy-curl").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText($("curl-command").textContent);
$("copy-status").textContent = "Copied. Run it in a terminal to create a job.";
} catch {
$("copy-status").textContent = "Clipboard access is unavailable. Select and copy the command above.";
}
});
const connect = async () => {
try {
await api("/health");
const config = await api("/playground/config");
model = config.model;
readyLabel = config.runtime === "mlx" ? "Server ready · MLX" : "Server ready · model loaded";
$("connection").textContent = readyLabel;
$("lifetime").textContent = config.runtime === "mlx"
? "MLX keeps the server and prompt cache available, but releases model components between phases to limit unified-memory use. Closing this page does not cancel a job."
: "The model stays loaded until you stop the server. Closing this page does not cancel a job.";
$("model").textContent = model;
const d = config.defaults;
const facts = [];
if (d.width && d.height) facts.push(`${d.width} × ${d.height}`);
if (d.num_frames) facts.push(`${d.num_frames} frames`);
if (d.fps) facts.push(`${d.fps} fps`);
if (d.seed != null) $("seed").placeholder = String(d.seed);
$("settings").textContent = facts.length ? `Server defaults · ${facts.join(" · ")}` : "Resolution and sampling come from the server configuration.";
setBusy(false);
updateCurl();
const id = new URL(location.href).searchParams.get("job");
if (id) await followJob(id);
else await refreshJobs();
} catch (error) {
$("connection").textContent = "Server unavailable";
$("history-status").textContent = "Start the H3 server, then reload this page.";
showError(`${error.message} Check the server terminal and reload this page when model loading completes.`);
}
};
connect();
})();
+12 -9
View File
@@ -11,7 +11,7 @@ from contextlib import suppress
from typing import Any
from fastapi import APIRouter, HTTPException, Path, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse
from pydantic import ValidationError
from starlette.background import BackgroundTask
from starlette.datastructures import UploadFile
@@ -233,7 +233,7 @@ async def _run_generation(
{
"status": VideoGenerationStatus.FAILED,
"error": {
"code": 500,
"code": "generation_failed",
"message": str(error)
},
"inference_time_s": time.perf_counter() - started if started else 0.0,
@@ -331,6 +331,9 @@ async def _parse_video_request(raw_request: Request) -> VideoGenerationRequest:
async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> GenerationRequest:
try:
# Runtime-specific checks (e.g. MLX's supported-field allowlist) run
# before the CUDA-oriented model/LoRA validation below.
get_serving_engine().validate_video_request(request)
validate_model_and_lora(request, get_server_args(), get_served_model_name())
await prepare_reference_media(request_id, request, get_output_dir())
return await asyncio.to_thread(
@@ -421,15 +424,14 @@ async def list_videos(
)
@router.get("/{video_id}", response_model=None)
async def retrieve_video(video_id: str = Path(...)) -> VideoResponse | JSONResponse:
@router.get("/{video_id}", response_model=VideoResponse)
async def retrieve_video(video_id: str = Path(...)) -> VideoResponse:
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
response = VideoResponse(**job)
if response.status is VideoGenerationStatus.FAILED:
return JSONResponse(status_code=500, content=response.model_dump(mode="json"))
return response
# A failed generation is still a successfully retrieved resource. SDK
# polling helpers need its terminal status, not a retryable HTTP error.
return VideoResponse(**job)
@router.delete("/{video_id}", response_model=VideoDeleteResponse)
@@ -457,7 +459,8 @@ async def delete_video(video_id: str = Path(...)) -> VideoDeleteResponse:
@router.get("/{video_id}/content")
async def download_video_content(video_id: str = Path(...), variant: str | None = Query(None)) -> FileResponse:
del variant
if variant not in {None, "video"}:
raise HTTPException(status_code=400, detail="Only the video content variant is supported")
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
+15
View File
@@ -159,6 +159,13 @@ class FastVideoArgs:
vae_cpu_offload: bool = True
pin_cpu_memory: bool = True
# MiniMax-H3 inference load order. ``None`` (auto) defers DiT/VAE load until
# after the Qwen3-VL encoder is released, but only on unified-memory
# devices (GB10 / Spark). Discrete GPUs keep the encoder resident so a
# later ``generate()`` on the same worker can re-encode. Explicit True /
# False overrides the probe. Training never defers.
h3_sequential_load: bool | None = None
# Sequence-parallel MiniMax-H3 VAE (opt-in, default off). With SP > 1 the
# video VAE's temporal chunks (decode) and clips (reference encode) are
# round-robined across the sequence-parallel ranks and reassembled
@@ -714,6 +721,14 @@ class FastVideoArgs:
"Pin memory for CPU offload. Only added as a temp workaround if it throws \"CUDA error: invalid argument\". "
"Should be enabled in almost all cases",
)
parser.add_argument(
"--h3-sequential-load",
action=argparse.BooleanOptionalAction,
default=None,
help="MiniMax-H3: encode with Qwen3-VL, release that encoder, then load DiT and VAEs. "
"Omit for auto (on for unified-memory devices such as GB10; off on discrete GPUs). "
"Pass --no-h3-sequential-load to keep the encoder resident for later generate() calls.",
)
parser.add_argument(
"--vae-parallel-decode",
action=StoreBoolean,
+80 -12
View File
@@ -303,34 +303,102 @@ def quantize_matrix(weight, spec: MLXQuantizationSpec | None):
)
def linear(x, weight, bias=None):
# Affine quantized_matmul is slower than dequantize + steel GEMM at H3's packed
# token width. Measured on Apple M4 Max / MLX 0.32.2, INT6 group 64, BF16 acts,
# Q 5376→7168: M=256 qmm is faster; M=512 dequant+GEMM is +6.4%; M≥1024 ~10%.
# 832×480×124 packed M is ~14862–14994. H3 opts into this path explicitly;
# shared FastWan and Wan 2.2 linears stay on quantized_matmul. Do not cache
# dequantized weights. Override: FASTVIDEO_MLX_DQ_GEMM=0 off, =1 measured
# floor, =<int> explicit floor.
_AFFINE_DQ_GEMM_BITS = frozenset({2, 3, 4, 5, 6, 8})
MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M = 768
_dq_gemm_engaged = 0
_dq_gemm_logged = False
def reset_dq_gemm_telemetry() -> None:
global _dq_gemm_engaged
_dq_gemm_engaged = 0
def dq_gemm_engaged() -> int:
return _dq_gemm_engaged
def affine_dq_gemm_min_m() -> int | None:
raw = os.environ.get("FASTVIDEO_MLX_DQ_GEMM", "1").strip().lower()
if raw in {"", "0", "off", "false", "no"}:
return None
if raw in {"1", "on", "true", "yes"}:
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
try:
value = int(raw)
except ValueError:
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
if value <= 0:
return None
return value
def _matmul_leading_rows(x) -> int:
last = int(x.shape[-1]) if x.ndim else 0
if last <= 0:
return 0
return int(x.size) // last
def _quantized_linear(x, weight: QuantizedMatrix, *, use_affine_dq_gemm: bool = False):
import mlx.core as mx
if isinstance(weight, QuantizedMatrix):
y = mx.quantized_matmul(
x,
global _dq_gemm_engaged, _dq_gemm_logged
spec = weight.spec
min_m = affine_dq_gemm_min_m() if use_affine_dq_gemm else None
rows = _matmul_leading_rows(x)
if (min_m is not None and spec.mode == "affine" and spec.bits in _AFFINE_DQ_GEMM_BITS
and spec.group_size is not None and rows >= min_m):
dequantized = mx.dequantize(
weight.weight,
weight.scales,
weight.biases,
transpose=True,
group_size=weight.spec.group_size,
bits=weight.spec.bits,
mode=weight.spec.mode,
).astype(x.dtype)
else:
y = x @ weight.T
group_size=spec.group_size,
bits=spec.bits,
mode=spec.mode,
dtype=x.dtype,
)
y = (x @ dequantized.T).astype(x.dtype)
_dq_gemm_engaged += 1
if not _dq_gemm_logged:
_dq_gemm_logged = True
logger.info("affine dequant+GEMM engaged (rows=%d, floor=%d, bits=%s)", rows, min_m, spec.bits)
return y
return mx.quantized_matmul(
x,
weight.weight,
weight.scales,
weight.biases,
transpose=True,
group_size=spec.group_size,
bits=spec.bits,
mode=spec.mode,
).astype(x.dtype)
def linear(x, weight, bias=None, *, use_affine_dq_gemm: bool = False):
y = (_quantized_linear(x, weight, use_affine_dq_gemm=use_affine_dq_gemm)
if isinstance(weight, QuantizedMatrix) else x @ weight.T)
if bias is not None:
y = y + bias
return y
def _use_fast_norm() -> bool:
"""Opt-in to MLX's fused ``mx.fast`` normalization kernels.
"""Opt-in to MLX's fused ``mx.fast`` normalization kernels for Wan.
Off by default so the numerically-explicit reference path stays the
baseline. Set ``FASTVIDEO_MLX_FAST_NORM=1`` to route LayerNorm/RMSNorm
through single fused Metal kernels (fewer intermediates, less memory
traffic) and benchmark the speedup.
H3 uses its own fused RMSNorm default and does not consult this toggle.
"""
import os
+268 -34
View File
@@ -26,7 +26,13 @@ Apple Silicon memory controls:
the repeated AdaLN projection work from each denoising step.
- **Affine INT8, INT6, or INT4 quantization** of attention/FFN matrices
(group size 64). Modulation, embeddings, norms, and input/output projections
remain in higher precision.
remain in higher precision. Quantization is weight-only: attention Q/K/V
stay BF16 (or the selected activation dtype).
- **Optional VSA.** Dense conversion still drops
``transformer_blocks.*.attn.to_gate_compress.weight``. ``--include-vsa``
keeps those 50 projections, quantizes them with the same affine grid, and
records ``vsa.capable`` in the manifest. Runtime VSA is opt-in and never
enabled for dense-only checkpoints.
Checkpoint layout: the released H3 checkpoint uses the diffusers reference
module names 1:1 (``transformer_blocks.{i}.attn.to_out.0.weight``,
@@ -55,16 +61,32 @@ from fastvideo.mlx_runtime.fastwan import (
MLXQuantizationSpec,
QuantizedMatrix,
ensure_quantization_supported,
linear,
linear as _shared_linear,
quantize_matrix,
rms_norm,
silu,
timestep_embedding,
weight_dtype,
)
from fastvideo.mlx_runtime.minimax_h3_vsa import (
MiniMaxH3VSAConfig,
MiniMaxH3VSAGeometry,
MiniMaxH3VSAStats,
VSA_GATE_KEY_SUFFIX,
build_h3_tile_geometry,
dense_only_vsa_error,
dit_seq_shape_from_layout,
h3_vsa_attention,
prefix_segments_from_layout,
)
logger = init_logger(__name__)
def linear(x, weight, bias=None):
"""Run an H3 linear with its measured wide-row affine dispatch enabled."""
return _shared_linear(x, weight, bias, use_affine_dq_gemm=True)
# ---------------------------------------------------------------------------
# Constants (mirrors fastvideo/pipelines/basic/minimax_h3/packing.py)
# ---------------------------------------------------------------------------
@@ -107,18 +129,22 @@ _QUANTIZABLE_SUFFIXES = (
"ff.net.0.proj.weight",
"ff.net.2.weight",
)
_VSA_QUANTIZABLE_SUFFIXES = _QUANTIZABLE_SUFFIXES + (VSA_GATE_KEY_SUFFIX, )
# The released FastH3 student carries VSA routing projections. The MLX path is
# dense, so retaining these weights wastes about 3.6 GiB without affecting a
# single output value.
# The released FastH3 student carries VSA routing projections. Dense MLX
# conversion drops them (~3.6 GiB BF16) because they do not affect fused SDPA.
# Pass include_vsa=True to keep and quantize them.
_IGNORED_DENSE_KEY_PARTS = ("attn.to_gate_compress", )
def _is_quantizable(key: str) -> bool:
return key.endswith(_QUANTIZABLE_SUFFIXES)
def _is_quantizable(key: str, *, include_vsa: bool = False) -> bool:
suffixes = _VSA_QUANTIZABLE_SUFFIXES if include_vsa else _QUANTIZABLE_SUFFIXES
return key.endswith(suffixes)
def _is_ignored_dense_key(key: str) -> bool:
def _is_ignored_dense_key(key: str, *, include_vsa: bool = False) -> bool:
if include_vsa:
return False
return any(part in key for part in _IGNORED_DENSE_KEY_PARTS)
@@ -473,7 +499,57 @@ def apply_h3_rotary(x, cos, sin):
return mx.concatenate([out.astype(x.dtype), x_pass], axis=-1)
def _attention(weights: dict[str, Any], x, cos, sin, *, num_heads: int, head_dim: int, eps: float, use_rope: bool):
def _gate_weight_is_active(weight: Any) -> bool:
import mlx.core as mx
if weight is None:
return False
if isinstance(weight, QuantizedMatrix):
# Affine quantization clamps scales above zero even for an all-zero gate.
active = mx.any(weight.weight != 0)
if weight.biases is not None:
active = active | mx.any(weight.biases != 0)
return bool(active.item())
return bool(mx.any(weight != 0).item())
def _h3_rms_norm(x, weight, eps: float):
"""H3 uses fused RMSNorm by default; FASTVIDEO_MLX_FAST_NORM is Wan-only."""
import mlx.core as mx
return mx.fast.rms_norm(x, weight, eps)
_compiled_modulation = None
def _modulate(hidden_states, scale, shift):
"""Apply row-expanded AdaLN scale and shift through one reusable graph."""
import mlx.core as mx
global _compiled_modulation
if _compiled_modulation is None:
_compiled_modulation = mx.compile(lambda value, scales, shifts: value * scales + shifts, shapeless=True)
return _compiled_modulation(hidden_states, scale, shift)
def _attention(
weights: dict[str, Any],
x,
cos,
sin,
*,
num_heads: int,
head_dim: int,
eps: float,
use_rope: bool,
vsa_geometry: MiniMaxH3VSAGeometry | None = None,
vsa_sparsity: float = 0.0,
vsa_exempt: bool = True,
vsa_impl: str = "auto",
vsa_stats: MiniMaxH3VSAStats | None = None,
use_gate_compress: bool = False,
):
"""H3 attention: bias-free qkv, per-head qk RMSNorm, partial RoPE."""
import mlx.core as mx
@@ -481,19 +557,43 @@ def _attention(weights: dict[str, Any], x, cos, sin, *, num_heads: int, head_dim
q = linear(x, weights["attn.to_q.weight"]).reshape(seq_len, num_heads, head_dim)
k = linear(x, weights["attn.to_k.weight"]).reshape(seq_len, num_heads, head_dim)
v = linear(x, weights["attn.to_v.weight"]).reshape(seq_len, num_heads, head_dim)
q = rms_norm(q, weights["attn.norm_q.weight"], eps=eps)
k = rms_norm(k, weights["attn.norm_k.weight"], eps=eps)
q = _h3_rms_norm(q, weights["attn.norm_q.weight"], eps)
k = _h3_rms_norm(k, weights["attn.norm_k.weight"], eps)
if use_rope:
q = apply_h3_rotary(q, cos, sin)
k = apply_h3_rotary(k, cos, sin)
# contiguous() guards against MLX fused-SDPA mis-computation on strided views.
attended = mx.fast.scaled_dot_product_attention(
mx.contiguous(q.transpose(1, 0, 2))[None],
mx.contiguous(k.transpose(1, 0, 2))[None],
mx.contiguous(v.transpose(1, 0, 2))[None],
scale=head_dim**-0.5,
)[0].transpose(1, 0, 2)
attended = mx.contiguous(attended.reshape(seq_len, num_heads * head_dim))
gate = None
if use_gate_compress and "attn.to_gate_compress.weight" in weights:
gate = linear(x, weights["attn.to_gate_compress.weight"]).reshape(seq_len, num_heads, head_dim)
use_vsa = (vsa_geometry is not None and use_rope and (vsa_sparsity > 0.0 or gate is not None)
and vsa_geometry.total_seq_length == seq_len)
if use_vsa:
assert vsa_geometry is not None
attended = h3_vsa_attention(
q,
k,
v,
vsa_geometry,
sparsity=vsa_sparsity,
exempt=vsa_exempt,
gate_compress=gate,
impl=vsa_impl, # type: ignore[arg-type]
stats=vsa_stats,
)
else:
if vsa_stats is not None and use_rope:
vsa_stats.impl = "dense"
if vsa_geometry is not None:
vsa_stats.num_prefix_tiles = vsa_geometry.num_prefix_tiles
vsa_stats.num_video_tiles = vsa_geometry.num_video_tiles
vsa_stats.video_keep = vsa_geometry.num_video_tiles
attended = mx.fast.scaled_dot_product_attention(
q.transpose(1, 0, 2)[None],
k.transpose(1, 0, 2)[None],
v.transpose(1, 0, 2)[None],
scale=head_dim**-0.5,
)[0].transpose(1, 0, 2)
attended = attended.reshape(seq_len, num_heads * head_dim)
return linear(attended, weights["attn.to_out.0.weight"])
@@ -532,12 +632,18 @@ def _transformer_block(
head_dim: int,
norm_eps: float,
qk_norm_eps: float,
vsa_geometry: MiniMaxH3VSAGeometry | None = None,
vsa_sparsity: float = 0.0,
vsa_exempt: bool = True,
vsa_impl: str = "auto",
vsa_stats: MiniMaxH3VSAStats | None = None,
use_gate_compress: bool = False,
):
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = tables
residual = hidden_states
normed = rms_norm(hidden_states, weights["norm1.weight"], eps=norm_eps)
normed = normed * (1.0 + scale_msa[adaln_indices]) + shift_msa[adaln_indices]
normed = _h3_rms_norm(hidden_states, weights["norm1.weight"], norm_eps)
normed = _modulate(normed, (1.0 + scale_msa)[adaln_indices], shift_msa[adaln_indices])
attn_output = _attention(
weights,
normed.astype(weight_dtype(weights["attn.to_q.weight"])),
@@ -547,12 +653,18 @@ def _transformer_block(
head_dim=head_dim,
eps=qk_norm_eps,
use_rope=True,
vsa_geometry=vsa_geometry,
vsa_sparsity=vsa_sparsity,
vsa_exempt=vsa_exempt,
vsa_impl=vsa_impl,
vsa_stats=vsa_stats,
use_gate_compress=use_gate_compress,
)
hidden_states = residual + gate_msa[adaln_indices] * attn_output
residual = hidden_states
normed = rms_norm(hidden_states, weights["norm2.weight"], eps=norm_eps)
normed = normed * (1.0 + scale_mlp[adaln_indices]) + shift_mlp[adaln_indices]
normed = _h3_rms_norm(hidden_states, weights["norm2.weight"], norm_eps)
normed = _modulate(normed, (1.0 + scale_mlp)[adaln_indices], shift_mlp[adaln_indices])
ff_output = _feed_forward(weights, normed.astype(weight_dtype(weights["ff.net.0.proj.weight"])))
return residual + gate_mlp[adaln_indices] * ff_output
@@ -567,7 +679,7 @@ def _refiner_block(
qk_norm_eps: float,
):
residual = hidden_states
normed = rms_norm(hidden_states, weights["norm1.weight"], eps=norm_eps)
normed = _h3_rms_norm(hidden_states, weights["norm1.weight"], norm_eps)
attn_output = _attention(
weights,
normed.astype(weight_dtype(weights["attn.to_q.weight"])),
@@ -579,7 +691,7 @@ def _refiner_block(
use_rope=False,
)
hidden_states = residual + attn_output
normed = rms_norm(hidden_states, weights["norm2.weight"], eps=norm_eps)
normed = _h3_rms_norm(hidden_states, weights["norm2.weight"], norm_eps)
return hidden_states + _feed_forward(weights, normed.astype(weight_dtype(weights["ff.net.0.proj.weight"])))
@@ -643,6 +755,11 @@ class MLXMiniMaxH3DiT:
self.final_norm_eps = float(config["final_norm_eps"])
self.patch_dim = self.in_channels * math.prod(self.patch_size)
self._adaln_cache: MiniMaxH3StepCache | None = None
self.vsa_config = MiniMaxH3VSAConfig()
self._vsa_geometry: MiniMaxH3VSAGeometry | None = None
self._vsa_capable = any("attn.to_gate_compress.weight" in block for block in blocks)
self._gate_active: list[bool | None] = [None] * len(blocks)
self.last_vsa_stats: MiniMaxH3VSAStats | None = None
# -- conditioning ------------------------------------------------------
@@ -676,7 +793,7 @@ class MLXMiniMaxH3DiT:
norm_eps=self.norm_eps,
qk_norm_eps=self.qk_norm_eps,
)
return rms_norm(hidden, self.weights["token_refiner.final_norm.weight"], eps=self.final_norm_eps)
return _h3_rms_norm(hidden, self.weights["token_refiner.final_norm.weight"], self.final_norm_eps)
# -- AdaLN cache --------------------------------------------------------
@@ -712,6 +829,70 @@ class MLXMiniMaxH3DiT:
block["adaln_proj.linear.bias"] = None
return self._adaln_cache
@property
def vsa_capable(self) -> bool:
return self._vsa_capable
def configure_vsa(self, config: MiniMaxH3VSAConfig | None) -> None:
config = config or MiniMaxH3VSAConfig()
if config.enabled and not self._vsa_capable:
raise dense_only_vsa_error("this MLX H3 DiT")
if any(layer >= len(self.blocks) for layer in config.dense_layers):
raise ValueError(f"VSA dense_layers must be below the model's block count ({len(self.blocks)}).")
self.vsa_config = config
self._vsa_geometry = None
self.reset_vsa_stats()
def reset_vsa_stats(self) -> None:
self.last_vsa_stats = MiniMaxH3VSAStats(
configured_sparsity=self.vsa_config.sparsity,
tile_size=self.vsa_config.tile_size,
prefix_mode=self.vsa_config.prefix_mode,
) if self.vsa_config.enabled else None
def prepare_vsa_geometry(self, layout: MiniMaxH3PackedLayout) -> MiniMaxH3VSAGeometry | None:
if not self.vsa_config.enabled:
self._vsa_geometry = None
return None
prefix = tuple(segment for segment in prefix_segments_from_layout(layout, self.patch_size) if segment > 0)
video_shape = dit_seq_shape_from_layout(layout, self.patch_size)
geometry = self._vsa_geometry
if geometry is not None and (geometry.prefix_segments, geometry.dit_seq_shape,
geometry.tile_elems) == (prefix, video_shape, self.vsa_config.tile_size):
return geometry
try:
geometry = build_h3_tile_geometry(prefix, video_shape, self.vsa_config.tile_size)
except ValueError as error:
raise ValueError(f"H3 VSA cannot run with this packed layout (prefix={prefix}, "
f"video={video_shape}, tile_size={self.vsa_config.tile_size}): {error}. "
"Disable VSA or pick a supported tile size (64 or 256).") from error
self._vsa_geometry = geometry
return self._vsa_geometry
def _block_gate_active(self, block_index: int) -> bool:
cached = self._gate_active[block_index]
if cached is not None:
return cached
weight = self.blocks[block_index].get("attn.to_gate_compress.weight")
active = _gate_weight_is_active(weight)
self._gate_active[block_index] = active
if weight is not None and not active:
logger.info_once("MiniMax H3 VSA compression gate is all-zero; skipping the pooled branch.")
return active
def _vsa_block_kwargs(self, block_index: int, step_index: int) -> dict[str, Any]:
geometry = self._vsa_geometry if self.vsa_config.enabled else None
sparsity = self.vsa_config.layer_sparsity(block_index, step_index) if geometry is not None else 0.0
stats = MiniMaxH3VSAStats() if self.vsa_config.enabled else None
return {
"vsa_geometry": geometry,
"vsa_sparsity": sparsity,
"vsa_exempt": self.vsa_config.exempt,
"vsa_impl": self.vsa_config.impl,
"vsa_stats": stats,
"use_gate_compress": geometry is not None and self._block_gate_active(block_index),
}
# -- forward ------------------------------------------------------------
def forward(
@@ -734,9 +915,14 @@ class MLXMiniMaxH3DiT:
projects *all* packed rows through both heads and then selects; this
selects first and projects only the relevant rows — row-wise
identical math at a fraction of the cost.
This entrypoint is dense-only. VSA requires ``forward_with_cache``
with a packed layout and step index.
"""
import mlx.core as mx
if self.vsa_config.enabled:
raise ValueError("VSA requires forward_with_cache() with layout and step_index; forward() is dense-only.")
sequence_length = position_ids.shape[0]
cos, sin = rope_cos_sin(position_ids, self.rope_freq_dim, self.rope_theta)
@@ -760,7 +946,7 @@ class MLXMiniMaxH3DiT:
temb = self.compute_temb(timesteps)
adaln_indices = (timestep_indices * MINIMAX_H3_MODALITY_NUM + token_tags).astype(mx.int32)
for block in self.blocks:
for block_index, block in enumerate(self.blocks):
tables = _adaln_tables(block, temb)
packed = _transformer_block(
block,
@@ -773,6 +959,7 @@ class MLXMiniMaxH3DiT:
head_dim=self.head_dim,
norm_eps=self.norm_eps,
qk_norm_eps=self.qk_norm_eps,
**self._vsa_block_kwargs(block_index, 0),
)
mx.eval(packed) # per-block sync: see forward_with_cache note
@@ -782,7 +969,7 @@ class MLXMiniMaxH3DiT:
self.weights["norm_out.linear.bias"],
)
shift_rows, scale_rows = mx.split(shift_scale, 2, axis=-1)
normed = rms_norm(packed, self.weights["norm_out.norm.weight"], eps=self.final_norm_eps)
normed = _h3_rms_norm(packed, self.weights["norm_out.norm.weight"], self.final_norm_eps)
normed = normed * (1.0 + scale_rows[timestep_indices]) + shift_rows[timestep_indices]
video_output = linear(
normed[_as_mx_indices(video_indices)].astype(weight_dtype(self.weights["proj_out.weight"])),
@@ -805,6 +992,7 @@ class MLXMiniMaxH3DiT:
layout: MiniMaxH3PackedLayout,
step_timesteps: np.ndarray,
row_timestep_inverse: np.ndarray,
step_index: int = 0,
):
"""Denoise-step forward served entirely from the AdaLN cache.
@@ -817,6 +1005,8 @@ class MLXMiniMaxH3DiT:
if self._adaln_cache is None:
raise RuntimeError("precompute_adaln() must run before forward_with_cache().")
if self.vsa_config.enabled:
self.prepare_vsa_geometry(layout)
cache = self._adaln_cache
positions = mx.array(cache.positions(step_timesteps))
@@ -848,7 +1038,8 @@ class MLXMiniMaxH3DiT:
# Sync per block: without this, MLX enqueues the entire 50-block graph
# while the GPU lags behind, allocating every intermediate at once
# (observed as an OOM kill on 36 GiB Macs at 480x832).
for block, tables in zip(self.blocks, cache.block_tables, strict=True):
for block_index, (block, tables) in enumerate(zip(self.blocks, cache.block_tables, strict=True)):
vsa_kwargs = self._vsa_block_kwargs(block_index, step_index)
packed = _transformer_block(
block,
packed,
@@ -860,11 +1051,14 @@ class MLXMiniMaxH3DiT:
head_dim=self.head_dim,
norm_eps=self.norm_eps,
qk_norm_eps=self.qk_norm_eps,
**vsa_kwargs,
)
mx.eval(packed)
if self.last_vsa_stats is not None:
self.last_vsa_stats.record(vsa_kwargs["vsa_stats"])
row_positions = positions[row_inverse]
normed = rms_norm(packed, self.weights["norm_out.norm.weight"], eps=self.final_norm_eps)
normed = _h3_rms_norm(packed, self.weights["norm_out.norm.weight"], self.final_norm_eps)
normed = normed * (1.0 + cache.norm_out_scale[row_positions]) + cache.norm_out_shift[row_positions]
video_output = linear(
normed[mx.array(layout.video_indices)].astype(weight_dtype(self.weights["proj_out.weight"])),
@@ -932,6 +1126,7 @@ def mlx_h3_dit_from_diffusers_safetensors(
num_blocks: int | None = None,
quantization: str | MLXQuantizationSpec | None = None,
adaln_cache_timesteps: np.ndarray | None = None,
include_vsa: bool = False,
) -> MLXMiniMaxH3DiT:
"""Load the released H3 transformer (diffusers layout) into MLX.
@@ -944,6 +1139,10 @@ def mlx_h3_dit_from_diffusers_safetensors(
block at a time while the checkpoint is read. The 13B projection weights
are never retained together and are omitted from the returned model. This
is the memory-bounded build path for the released 33B student.
``include_vsa`` keeps ``attn.to_gate_compress`` matrices and quantizes them
with the same affine grid as the other linear weights. Dense conversion
continues to drop them.
"""
import mlx.core as mx
transformer_path = Path(transformer_path)
@@ -1020,7 +1219,7 @@ def mlx_h3_dit_from_diffusers_safetensors(
for shard in _safetensors_shards(transformer_path):
shard_arrays = mx.load(str(shard))
for key, source in shard_arrays.items():
if _is_ignored_dense_key(key):
if _is_ignored_dense_key(key, include_vsa=include_vsa):
continue
if temb is not None and key.startswith("time_embedder."):
continue
@@ -1051,7 +1250,7 @@ def mlx_h3_dit_from_diffusers_safetensors(
block["adaln_proj.linear.bias"] = None
del pending_adaln[index]
continue
if spec is not None and _is_quantizable(key) and not keep_fp32:
if spec is not None and _is_quantizable(key, include_vsa=include_vsa) and not keep_fp32:
value = quantize_matrix(array, spec)
del array
else:
@@ -1087,9 +1286,19 @@ def mlx_h3_dit_from_diffusers_safetensors(
raise KeyError(f"Missing transformer block weights for indices {missing}.")
if any(block is None for block in refiner):
raise KeyError("Missing token refiner weights.")
loaded_blocks = [block for block in blocks if block is not None]
if include_vsa:
missing_gates = [
index for index, block in enumerate(loaded_blocks) if "attn.to_gate_compress.weight" not in block
]
if missing_gates:
raise KeyError("VSA conversion requested but gate projections are missing for transformer blocks "
f"{missing_gates}. The source checkpoint must contain "
f"`transformer_blocks.*.{VSA_GATE_KEY_SUFFIX}`.")
logger.info("Retained %d VSA gate projections (%s).", len(loaded_blocks), VSA_GATE_KEY_SUFFIX)
dit = MLXMiniMaxH3DiT(
weights,
[block for block in blocks if block is not None],
loaded_blocks,
[block for block in refiner if block is not None],
config,
)
@@ -1101,6 +1310,19 @@ H3_FORMAT_VERSION = 1
H3_WEIGHTS_FILENAME = "mlx_h3_dit.safetensors"
H3_MANIFEST_FILENAME = "mlx_h3_dit.json"
def mlx_h3_checkpoint_vsa_capable(checkpoint_dir: str | Path) -> bool:
"""True when a saved MLX H3 checkpoint retained the VSA gate projections."""
manifest_path = Path(checkpoint_dir) / H3_MANIFEST_FILENAME
if not manifest_path.is_file():
return False
manifest = json.loads(manifest_path.read_text())
if bool((manifest.get("vsa") or {}).get("capable")):
return True
quantized_keys = manifest.get("quantized_keys") or {}
return any("attn.to_gate_compress.weight" in key for key in quantized_keys)
_DTYPE_TO_NAME = {"float16": "fp16", "bfloat16": "bf16", "float32": "fp32"}
@@ -1183,6 +1405,18 @@ def save_mlx_h3_checkpoint(dit: MLXMiniMaxH3DiT, checkpoint_dir: str | Path) ->
},
"quantized_keys": quantized,
"adaln_cache": cache_manifest,
"vsa": {
"capable":
bool(dit.vsa_capable),
"num_gate_matrices":
sum(1 for block in dit.blocks if "attn.to_gate_compress.weight" in block),
"gate_key_suffix":
VSA_GATE_KEY_SUFFIX,
"attention_activations":
"bf16",
"note": ("Gate projections are quantized with the checkpoint's affine INT8/INT6/INT4 "
"weight-only grid. VSA attention Q/K/V remain unquantized activations."),
},
}
weights_path = checkpoint_dir / H3_WEIGHTS_FILENAME
mx.save_safetensors(str(weights_path), arrays)
@@ -7,8 +7,8 @@ hidden states after the first 50 language-model layers plus the per-token
modality tags for text prompts.
Memory contract for the 36 GiB tier: the released conditioner is ~66 GB of
BF16 and never becomes resident. Tensors are memory-mapped per-key from the
safetensors shards and only the pieces a given forward needs are materialized:
BF16 and never becomes resident. Tensors are read per-key from the safetensors
shards and only the pieces a given forward needs are materialized:
- token embedding table row-gathered per batch (full table never copied);
- one decoder layer (~1 GB BF16) resident at a time, computed in FP32,
@@ -66,7 +66,7 @@ class ConditionerConfig:
class _ShardIndex:
"""Per-key memory-mapped access across the diffusers safetensors shards.
"""Per-key access across the diffusers safetensors shards.
Reads tensors directly through the safetensors header so BF16 weights
stream from disk without loading a shard (and without torch).
@@ -109,6 +109,19 @@ class _ShardIndex:
header, data_start = self._header_cache[shard]
return _read_safetensors_bf16(shard, key, header, data_start)
def get_mlx(self, key: str) -> mx.array:
"""Read one weight in FP32 without expanding BF16 on the CPU."""
shard = self.key_to_shard[key]
header, data_start = self._header_cache[shard]
if header[key]["dtype"] != "BF16":
return mx.array(np.asarray(self.get(key), dtype=np.float32))
raw = _read_bf16_words(shard, key, header, data_start)
weight = mx.array(raw).view(mx.bfloat16).astype(mx.float32)
# Finish each cast before constructing the layer graph, releasing its
# BF16 input instead of retaining a second copy of every layer weight.
mx.eval(weight)
return weight
def get_row(self, key: str, row: int) -> np.ndarray:
shard = self.key_to_shard[key]
header, data_start = self._header_cache[shard]
@@ -121,6 +134,21 @@ class _ShardIndex:
_DTYPES = {"F32": np.float32, "F16": np.float16, "I64": np.int64, "I32": np.int32}
def _read_bf16_words(path: str, key: str, header: dict, data_start: int) -> np.ndarray:
"""Bulk-read one BF16 tensor; avoid faulting in a large mmap during conversion."""
meta = header[key]
begin, end = meta["data_offsets"]
if begin < 0 or end < begin or (end - begin) % 2:
raise ValueError(f"Invalid BF16 data offsets {begin}:{end} in {path}:{key}")
count = (end - begin) // 2
with open(path, "rb") as handle:
handle.seek(data_start + begin)
raw = np.fromfile(handle, dtype=np.uint16, count=count)
if raw.size != count:
raise EOFError(f"Truncated safetensors tensor {path}:{key}: expected {count} BF16 values, got {raw.size}")
return raw.reshape(meta["shape"])
def _read_safetensors_bf16(path: str, key: str, header: dict, data_start: int) -> np.ndarray:
"""Read one tensor (any dtype incl. BF16) without loading the shard."""
meta = header[key]
@@ -128,8 +156,10 @@ def _read_safetensors_bf16(path: str, key: str, header: dict, data_start: int) -
count = end - begin
dtype = meta["dtype"]
if dtype == "BF16":
raw = np.memmap(path, dtype=np.uint16, mode="r", offset=data_start + begin, shape=(count // 2, ))
return (raw.astype(np.uint32) << 16).view(np.float32).reshape(meta["shape"])
raw = _read_bf16_words(path, key, header, data_start)
expanded = raw.astype(np.uint32)
expanded <<= 16 # Shift the owned conversion buffer, never the read-only mapping.
return expanded.view(np.float32).reshape(meta["shape"])
if dtype not in _DTYPES:
raise ValueError(f"Unsupported safetensors dtype {dtype} in {path}:{key}")
return np.asarray(
@@ -157,7 +187,9 @@ def _read_safetensors_row(path: str, key: str, row: int, header: dict, data_star
begin = int(meta["data_offsets"][0]) + row * row_count * item_size
if dtype == "BF16":
raw = np.memmap(path, dtype=np.uint16, mode="r", offset=data_start + begin, shape=(row_count, ))
return (raw.astype(np.uint32) << 16).view(np.float32).reshape(shape[1:])
expanded = raw.astype(np.uint32)
expanded <<= 16
return expanded.view(np.float32).reshape(shape[1:])
return np.array(
np.memmap(path, dtype=_DTYPES[dtype], mode="r", offset=data_start + begin, shape=(row_count, )),
copy=True,
@@ -276,7 +308,7 @@ class StreamedMiniMaxH3TextConditioner:
prefix = f"model.language_model.layers.{index}."
def w(name):
return mx.array(np.asarray(self.index.get(prefix + name)).astype(np.float32))
return self.index.get_mlx(prefix + name)
# Self-attention block.
residual = hidden
+237 -21
View File
@@ -33,6 +33,11 @@ from typing import Any
import numpy as np
from fastvideo.logger import init_logger
from fastvideo.mlx_runtime.frame_upsample import (
DEFAULT_PIXEL_UPSAMPLE_MODE,
PIXEL_UPSAMPLE_MODES,
upsample_frames,
)
from fastvideo.mlx_runtime.minimax_h3 import (
H3_MANIFEST_FILENAME,
MINIMAX_H3_AUDIO_SHIFT,
@@ -44,12 +49,15 @@ from fastvideo.mlx_runtime.minimax_h3 import (
audio_latent_num_frames,
build_packed_layout,
build_row_timesteps,
dense_only_vsa_error,
load_mlx_h3_checkpoint,
mlx_h3_checkpoint_vsa_capable,
temporal_position_grid,
unpatchify_video_tokens,
unpack_audio_tokens,
video_latent_num_frames,
)
from fastvideo.mlx_runtime.minimax_h3_vsa import MiniMaxH3VSAConfig
logger = init_logger(__name__)
@@ -62,6 +70,8 @@ class GenerationResult:
sample_rate: int
timings: dict[str, float] = field(default_factory=dict)
peak_memory_gib: dict[str, float] = field(default_factory=dict)
vsa: dict[str, Any] = field(default_factory=dict)
video_decode_backend: str = "h3-vae"
@dataclass(frozen=True)
@@ -95,6 +105,72 @@ def plan_fast_temporal(target_frames: int, factor: int = 2) -> FastTemporalPlan:
)
# Resampling from a smaller decode softens output the same way on every
# runtime; 0.4 matches the tuned Wan default without the halos that show
# up by ~0.8.
DEFAULT_FAST_SPATIAL_SHARPEN = 0.4
@dataclass(frozen=True)
class FastSpatialPlan:
"""Reduced-canvas geometry for spatial fast mode (RIFE's spatial twin)."""
target_height: int
target_width: int
stage1_height: int
stage1_width: int
canvas_height: int
canvas_width: int
scale: int
upsample_mode: str
sharpen: float
def plan_fast_spatial(
height: int,
width: int,
*,
scale: int = 2,
upsample_mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN,
) -> FastSpatialPlan:
"""Choose the smallest H3-valid canvas that covers ``target / scale``.
H3 geometry rounds *up* to the 32px model grid and center-crops after
decode — the same convention plain 720p generation uses via
``_model_canvas_size`` — so no size the full-resolution path accepts is
rejected here. The return trip to the target size runs in pixel space
after the VAE decode, never on latents; see
:mod:`fastvideo.mlx_runtime.frame_upsample` for why.
"""
if scale < 2:
raise ValueError(f"fast-spatial scale must be at least 2, got {scale}.")
if upsample_mode not in PIXEL_UPSAMPLE_MODES:
raise ValueError(f"Unsupported upsample mode: {upsample_mode!r} "
f"(expected one of {', '.join(PIXEL_UPSAMPLE_MODES)})")
if sharpen < 0:
raise ValueError(f"fast_spatial_sharpen must be non-negative, got {sharpen}.")
target_canvas_height, target_canvas_width = _model_canvas_size(height, width)
stage1_height = math.ceil(height / scale)
stage1_width = math.ceil(width / scale)
canvas_height, canvas_width = _model_canvas_size(stage1_height, stage1_width)
if canvas_height * canvas_width >= target_canvas_height * target_canvas_width:
raise ValueError(
f"fast-spatial scale {scale} does not reduce the H3 canvas for {height}x{width} "
f"(stage-1 canvas {canvas_width}x{canvas_height} vs {target_canvas_width}x{target_canvas_height}).")
return FastSpatialPlan(
target_height=height,
target_width=width,
stage1_height=stage1_height,
stage1_width=stage1_width,
canvas_height=canvas_height,
canvas_width=canvas_width,
scale=scale,
upsample_mode=upsample_mode,
sharpen=sharpen,
)
def _model_canvas_size(height: int, width: int) -> tuple[int, int]:
"""Round an exact output size up to H3's 32-pixel model grid."""
if height <= 0 or width <= 0:
@@ -198,10 +274,16 @@ def _validate_checkpoint_step_ladder(checkpoint_dir: str | Path, num_steps: int)
f"{num_steps}. Use the step count used during conversion (normally 4), or re-export the checkpoint.")
def _preflight_media_dependencies(*, fast: bool, fast_sharpen: float, rife_weights_dir: str | Path | None) -> None:
def _preflight_media_dependencies(*,
fast: bool,
fast_sharpen: float,
rife_weights_dir: str | Path | None,
fast_spatial: bool = False) -> None:
"""Fail before conditioning when required output dependencies are unavailable."""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg is required for MP4 muxing; install it before generation.")
if fast_spatial and importlib.util.find_spec("cv2") is None:
raise RuntimeError("OpenCV is required for --fast-spatial resampling.")
if not fast:
return
if fast_sharpen > 0 and importlib.util.find_spec("cv2") is None:
@@ -224,6 +306,9 @@ class MiniMaxH3MLXPipeline:
conditioner_dir: str | Path | None = None,
tokenizer_dir: str | Path | None = None,
metal_wired_limit_gib: float | None = None,
video_decode_backend: str = "h3-vae",
taeh3_checkpoint: str | Path | None = None,
taeh3_chunk_size: int = 5,
) -> None:
import mlx.core as mx
@@ -241,6 +326,15 @@ class MiniMaxH3MLXPipeline:
self.model_root = Path(model_root)
self.dit_checkpoint = Path(mlx_dit_checkpoint)
self.vae_dtype = vae_dtype
if video_decode_backend not in ("h3-vae", "taeh3"):
raise ValueError(f"Unknown H3 video decoder: {video_decode_backend}")
if taeh3_chunk_size < 1:
raise ValueError("taeh3_chunk_size must be positive.")
if taeh3_checkpoint is not None and video_decode_backend != "taeh3":
raise ValueError("taeh3_checkpoint requires video_decode_backend='taeh3'.")
self.video_decode_backend = video_decode_backend
self.taeh3_checkpoint = taeh3_checkpoint
self.taeh3_chunk_size = taeh3_chunk_size
self.prompt_cache_dir = Path(prompt_cache_dir) if prompt_cache_dir else None
self.conditioner_dir = Path(conditioner_dir) if conditioner_dir else self.model_root / "text_encoder"
self.tokenizer_dir = Path(tokenizer_dir) if tokenizer_dir else self.model_root / "tokenizer"
@@ -252,6 +346,8 @@ class MiniMaxH3MLXPipeline:
raise ValueError(f"H3 DiT patch_size must have three dimensions, got {patch_size}.")
self._dit_patch_size = (int(patch_size[0]), int(patch_size[1]), int(patch_size[2]))
self._dit_in_channels = int(dit_config["in_channels"])
self.last_dit_forward_s = 0.0
self.last_vsa_stats: dict[str, Any] | None = None
# -- input validation (before anything heavy loads) -------------------
@@ -261,7 +357,7 @@ class MiniMaxH3MLXPipeline:
missing.append(str(self.dit_checkpoint))
vae_dir = self.model_root / "vae"
audio_dir = self.model_root / "audio_vae"
if not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
if self.video_decode_backend == "h3-vae" and not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
missing.append(str(vae_dir))
if not (audio_dir.exists() and any(audio_dir.glob("*.safetensors"))):
missing.append(str(audio_dir))
@@ -353,6 +449,7 @@ class MiniMaxH3MLXPipeline:
seed: int,
num_steps: int = 4,
dit: Any | None = None,
vsa_config: MiniMaxH3VSAConfig | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Denoise joint latents; returns (normalized video rows, audio rows)."""
import mlx.core as mx
@@ -366,6 +463,10 @@ class MiniMaxH3MLXPipeline:
t0 = time.perf_counter()
dit = load_mlx_h3_checkpoint(self.dit_checkpoint)
logger.info("Loaded MLX H3 DiT from %s in %.1fs", self.dit_checkpoint, time.perf_counter() - t0)
if vsa_config is not None:
dit.configure_vsa(vsa_config)
if hasattr(dit, "reset_vsa_stats"):
dit.reset_vsa_stats()
layout = build_packed_layout(
len(token_tags),
@@ -377,6 +478,8 @@ class MiniMaxH3MLXPipeline:
text_token_tags=np.asarray(token_tags, dtype=np.int64),
video_temporal_scale=video_temporal_scale,
)
if getattr(dit, "vsa_config", None) is not None and dit.vsa_config.enabled:
dit.prepare_vsa_geometry(layout)
video_scheduler = MiniMaxH3SchedulerState.create(MINIMAX_H3_VIDEO_SHIFT, num_steps)
audio_scheduler = MiniMaxH3SchedulerState.create(MINIMAX_H3_AUDIO_SHIFT, num_steps)
@@ -399,6 +502,7 @@ class MiniMaxH3MLXPipeline:
x_v = mx.random.normal((target_video_rows, dit.patch_dim), key=video_key)
x_a = mx.random.normal((target_audio_rows, dit.audio_in_channels), key=audio_key)
text = mx.array(text_rows.astype(np.float32))
dit_forward_s = 0.0
for step_index in range(num_steps):
video_t = float(video_scheduler.timesteps[step_index])
@@ -410,6 +514,7 @@ class MiniMaxH3MLXPipeline:
condition_video_timestep=max(video_t, MINIMAX_H3_KEYFRAME_NOISE_AUG),
condition_audio_timestep=1.0,
)
step_started = time.perf_counter()
video_velocity, audio_velocity = dit.forward_with_cache(
x_v,
x_a,
@@ -417,6 +522,7 @@ class MiniMaxH3MLXPipeline:
layout=layout,
step_timesteps=unique,
row_timestep_inverse=inverse,
step_index=step_index,
)
# Only target rows are being denoised (no conditions in T2VA).
video_velocity = video_velocity[layout.num_condition_video_rows:]
@@ -424,6 +530,28 @@ class MiniMaxH3MLXPipeline:
x_v = video_scheduler.step(video_velocity, step_index, x_v)
x_a = audio_scheduler.step(audio_velocity, step_index, x_a)
mx.eval(x_v, x_a)
dit_forward_s += time.perf_counter() - step_started
self.last_dit_forward_s = dit_forward_s
stats = getattr(dit, "last_vsa_stats", None)
self.last_vsa_stats = None if stats is None else {
"enabled": bool(getattr(dit.vsa_config, "enabled", False)),
"configured_sparsity": stats.configured_sparsity,
"layer_sparsity": stats.layer_sparsity,
"achieved_sparsity": stats.achieved_sparsity,
"tile_size": stats.tile_size,
"prefix_mode": stats.prefix_mode,
"impl": stats.impl,
"num_prefix_tiles": stats.num_prefix_tiles,
"num_video_tiles": stats.num_video_tiles,
"video_keep": stats.video_keep,
"dense_fallback_reason": stats.dense_fallback_reason,
"attention_calls": stats.attention_calls,
"sparse_calls": stats.sparse_calls,
"impl_counts": stats.impl_counts,
"fallback_reasons": stats.fallback_reasons,
"checkpoint_vsa_capable": bool(getattr(dit, "vsa_capable", False)),
}
video_rows = np.asarray(x_v, dtype=np.float32)
audio_rows = np.asarray(x_a, dtype=np.float32)
@@ -447,6 +575,22 @@ class MiniMaxH3MLXPipeline:
from fastvideo.mlx_runtime.minimax_h3_video_vae import mlx_h3_video_vae_from_dir
geometry = self.resolve_geometry(height, width, num_frames, enforce_duration=False)
if self.video_decode_backend == "taeh3":
from fastvideo.mlx_runtime.minimax_h3_taeh3 import decode_latents_taeh3_mlx
if self._dit_in_channels != 24:
raise ValueError("TAEH3 requires a 24-channel H3 checkpoint.")
latents = unpatchify_video_tokens(video_rows, geometry["latent_frame_count"], geometry["latent_height"],
geometry["latent_width"], self._dit_in_channels, self._dit_patch_size)
pixels = decode_latents_taeh3_mlx(latents,
checkpoint_path=self.taeh3_checkpoint,
dtype=self.vae_dtype,
chunk_size=self.taeh3_chunk_size)
frames = (pixels[0] * 255.0).astype(np.uint8)
if frames.shape != (geometry["num_frames"], height, width, 3):
raise RuntimeError(f"TAEH3 produced unexpected frame shape: {frames.shape}")
_cleanup_mlx()
return frames
vae = mlx_h3_video_vae_from_dir(self.model_root / "vae", include_encoder=False, storage_dtype=self.vae_dtype)
expected_height = height // vae.spatial_compression_ratio
expected_width = width // vae.spatial_compression_ratio
@@ -576,21 +720,32 @@ class MiniMaxH3MLXPipeline:
# -- end-to-end ----------------------------------------------------------
def generate(
self,
prompt: str,
*,
output_path: str | Path,
height: int = 480,
width: int = 832,
num_frames: int = 124,
seed: int = 0,
num_steps: int = 4,
save_frames: bool = False,
tiled_video_decode: bool = True,
fast: bool = False,
fast_factor: int = 2,
fast_sharpen: float = 0.6,
rife_weights_dir: str | Path | None = None,
self,
prompt: str,
*,
output_path: str | Path,
height: int = 480,
width: int = 832,
num_frames: int = 124,
seed: int = 0,
num_steps: int = 4,
save_frames: bool = False,
tiled_video_decode: bool = True,
fast: bool = False,
fast_factor: int = 2,
fast_sharpen: float = 0.6,
rife_weights_dir: str | Path | None = None,
fast_spatial: bool = False,
fast_spatial_scale: int = 2,
fast_spatial_upsample_mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
fast_spatial_sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN,
vsa: bool = False,
vsa_sparsity: float = 0.9,
vsa_tile_size: int = 64,
vsa_prefix_mode: str = "exempt",
vsa_dense_first_n_steps: int = 0,
vsa_dense_layers: tuple[int, ...] = (),
vsa_impl: str = "auto",
) -> GenerationResult:
timings: dict[str, float] = {}
peaks: dict[str, float] = {}
@@ -598,12 +753,34 @@ class MiniMaxH3MLXPipeline:
if fast_sharpen < 0:
raise ValueError(f"fast_sharpen must be non-negative, got {fast_sharpen}.")
_validate_checkpoint_step_ladder(self.dit_checkpoint, num_steps)
vsa_config = MiniMaxH3VSAConfig(
enabled=vsa,
sparsity=vsa_sparsity,
tile_size=vsa_tile_size,
prefix_mode=vsa_prefix_mode, # type: ignore[arg-type]
dense_first_n_steps=vsa_dense_first_n_steps,
dense_layers=vsa_dense_layers,
impl=vsa_impl, # type: ignore[arg-type]
) if vsa else MiniMaxH3VSAConfig()
if vsa_config.enabled and not mlx_h3_checkpoint_vsa_capable(self.dit_checkpoint):
raise dense_only_vsa_error(self.dit_checkpoint)
spatial_plan = plan_fast_spatial(
height,
width,
scale=fast_spatial_scale,
upsample_mode=fast_spatial_upsample_mode,
sharpen=fast_spatial_sharpen,
) if fast_spatial else None
_preflight_media_dependencies(
fast=fast,
fast_sharpen=fast_sharpen,
rife_weights_dir=rife_weights_dir,
fast_spatial=fast_spatial,
)
canvas_height, canvas_width = _model_canvas_size(height, width)
if spatial_plan is not None:
canvas_height, canvas_width = spatial_plan.canvas_height, spatial_plan.canvas_width
else:
canvas_height, canvas_width = _model_canvas_size(height, width)
target_geometry = self.resolve_geometry(canvas_height, canvas_width, num_frames)
fast_plan = plan_fast_temporal(target_geometry["num_frames"], fast_factor) if fast else None
video_num_frames = fast_plan.source_frames if fast_plan is not None else target_geometry["num_frames"]
@@ -615,7 +792,7 @@ class MiniMaxH3MLXPipeline:
enforce_duration=fast_plan is None,
)
logger.info(
"Geometry: output=%dx%dx%d model=%dx%dx%d audio_frames=%d fast=%s",
"Geometry: output=%dx%dx%d model=%dx%dx%d audio_frames=%d fast=%s fast_spatial=%s",
width,
height,
target_geometry["num_frames"],
@@ -624,8 +801,17 @@ class MiniMaxH3MLXPipeline:
video_geometry["num_frames"],
target_geometry["num_frames"],
fast_plan,
spatial_plan,
)
if self.video_decode_backend == "taeh3":
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
started = time.perf_counter()
ensure_taeh3_checkpoint(self.taeh3_checkpoint)
timings["decoder_prepare_s"] = time.perf_counter() - started
logger.warning("TAEH3 is an approximate preview decoder; reconstruction differs from the full H3 VAE.")
_reset_peak_memory()
started = time.perf_counter()
text_rows, token_tags = self.encode_prompt(prompt)
@@ -645,8 +831,10 @@ class MiniMaxH3MLXPipeline:
video_temporal_scale=video_temporal_scale,
seed=seed,
num_steps=num_steps,
vsa_config=vsa_config,
)
timings["denoise_s"] = time.perf_counter() - started
timings["dit_forward_s"] = float(getattr(self, "last_dit_forward_s", 0.0))
peaks["denoise_gib"] = _peak_memory_gib()
del text_rows
_cleanup_mlx()
@@ -660,7 +848,10 @@ class MiniMaxH3MLXPipeline:
num_frames=video_geometry["num_frames"],
tiled=tiled_video_decode,
)
frames = _center_crop_frames(frames, height, width)
if spatial_plan is not None:
frames = _center_crop_frames(frames, spatial_plan.stage1_height, spatial_plan.stage1_width)
else:
frames = _center_crop_frames(frames, height, width)
timings["video_decode_s"] = time.perf_counter() - started
peaks["video_decode_gib"] = _peak_memory_gib()
_cleanup_mlx()
@@ -677,7 +868,8 @@ class MiniMaxH3MLXPipeline:
target_geometry["num_frames"],
model=model,
)
interpolated = _sharpen_frames(interpolated, fast_sharpen)
if spatial_plan is None:
interpolated = _sharpen_frames(interpolated, fast_sharpen)
frames = np.stack(interpolated)
if frames.shape[0] != target_geometry["num_frames"]:
raise RuntimeError(
@@ -690,6 +882,22 @@ class MiniMaxH3MLXPipeline:
del model
_cleanup_mlx()
if spatial_plan is not None:
started = time.perf_counter()
# One sharpen pass, at full resolution: RIFE and the resample soften
# for the same reason, so the stronger requested amount is applied
# once instead of stacking two unsharp masks.
sharpen = spatial_plan.sharpen if fast_plan is None else max(spatial_plan.sharpen, fast_sharpen)
frames = np.stack(
upsample_frames(
frames,
width=spatial_plan.target_width,
height=spatial_plan.target_height,
mode=spatial_plan.upsample_mode,
sharpen=sharpen,
))
timings["spatial_upsample_s"] = time.perf_counter() - started
_reset_peak_memory()
started = time.perf_counter()
waveform = self.decode_audio(audio_rows, num_frames=target_geometry["num_frames"])
@@ -700,6 +908,9 @@ class MiniMaxH3MLXPipeline:
started = time.perf_counter()
video_path = self.mux(frames, waveform, output_path)
timings["mux_s"] = time.perf_counter() - started
timings["generate_s"] = sum(
timings.get(key, 0.0) for key in ("decoder_prepare_s", "condition_s", "denoise_s", "video_decode_s",
"rife_s", "spatial_upsample_s", "audio_decode_s", "mux_s"))
result = GenerationResult(
video_path=str(video_path),
@@ -708,6 +919,11 @@ class MiniMaxH3MLXPipeline:
sample_rate=32000,
timings=timings,
peak_memory_gib=peaks,
video_decode_backend=self.video_decode_backend,
vsa=getattr(self, "last_vsa_stats", None) or {
"enabled": vsa_config.enabled,
"checkpoint_vsa_capable": mlx_h3_checkpoint_vsa_capable(self.dit_checkpoint),
},
)
logger.info("Generation complete: %s | timings=%s peaks=%s", video_path, timings, peaks)
return result
+195
View File
@@ -0,0 +1,195 @@
# SPDX-License-Identifier: Apache-2.0
"""Optional, approximate MiniMax H3 tiny decoder, executed entirely in MLX.
Architecture and temporal mapping adapted from madebyollin/taehv at
62f7591f59dfbb4c3c02b7a621d180a9eeaba26c (MIT, Ollin Boer Bohan).
See fastvideo/third_party/taehv/LICENSE. Weights remain in the upstream format.
This decoder consumes normalized diffusion latents; it does not use the full
H3 VAE's latent mean/std or pixel denormalization.
"""
from __future__ import annotations
import hashlib
import tempfile
import urllib.request
from pathlib import Path
from typing import Any
import numpy as np
TAEH3_REVISION = "62f7591f59dfbb4c3c02b7a621d180a9eeaba26c"
TAEH3_URL = f"https://raw.githubusercontent.com/madebyollin/taehv/{TAEH3_REVISION}/safetensors/taeh3.safetensors"
TAEH3_SHA256 = "4fd022bfcab08772fe0536b17ea1a3bbb5625be11e397868d1c5d891863d4c13"
def ensure_taeh3_checkpoint(checkpoint_path: str | Path | None = None) -> Path:
"""Fetch only pinned weights, atomically; never download executable code.
Explicit local safetensors paths may contain custom trained weights.
Managed cache entries must match the upstream SHA-256 digest.
"""
if checkpoint_path is not None:
path = Path(checkpoint_path).expanduser()
if not path.is_file():
raise FileNotFoundError(f"TAEH3 checkpoint not found: {path}")
if path.suffix != ".safetensors":
raise ValueError("The MLX TAEH3 decoder requires a .safetensors checkpoint.")
return path
path = Path.home() / ".cache/fastvideo/taehv/taeh3.safetensors"
def verify(candidate: Path) -> None:
with candidate.open("rb") as handle:
hasher = hashlib.sha256()
for chunk in iter(lambda: handle.read(1 << 20), b""):
hasher.update(chunk)
digest = hasher.hexdigest()
if digest != TAEH3_SHA256:
raise RuntimeError(f"TAEH3 checkpoint failed SHA-256 verification: {candidate}")
if path.exists():
verify(path)
return path
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".safetensors", delete=False) as temporary_file:
temporary = Path(temporary_file.name)
try:
with urllib.request.urlopen(TAEH3_URL, timeout=60) as response, temporary.open("wb") as handle:
while chunk := response.read(1 << 20):
handle.write(chunk)
verify(temporary)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
return path
class MLXTAEH3Decoder:
"""Decode H3 NTCHW latents with bounded temporal feature memory.
Chunks carry the last input of every MemBlock into the next chunk.
Chunking limits activation memory without resetting the causal state.
The full H3 VAE remains the quality reference, not a numerical oracle for
this independently trained tiny decoder.
"""
def __init__(self, checkpoint_path: str | Path, *, dtype: str = "fp32") -> None:
import mlx.core as mx
if dtype not in ("fp32", "fp16", "bf16"):
raise ValueError(f"Unsupported TAEH3 dtype: {dtype}")
self.dtype = {"fp32": mx.float32, "fp16": mx.float16, "bf16": mx.bfloat16}[dtype]
raw = mx.load(str(checkpoint_path))
expected: dict[str, tuple[int, ...]] = {
"decoder.1.weight": (256, 24, 3, 3),
"decoder.1.bias": (256, ),
"decoder.7.conv.weight": (256, 256, 1, 1),
"decoder.8.weight": (128, 256, 3, 3),
"decoder.13.conv.weight": (256, 128, 1, 1),
"decoder.14.weight": (64, 128, 3, 3),
"decoder.19.conv.weight": (128, 64, 1, 1),
"decoder.20.weight": (64, 64, 3, 3),
"decoder.22.weight": (12, 64, 3, 3),
"decoder.22.bias": (12, ),
}
for index, channels in ((3, 256), (4, 256), (5, 256), (9, 128), (10, 128), (11, 128), (15, 64), (16, 64), (17,
64)):
for layer in (0, 2, 4):
prefix = f"decoder.{index}.conv.{layer}"
expected[f"{prefix}.weight"] = (channels, channels * (2 if layer == 0 else 1), 3, 3)
expected[f"{prefix}.bias"] = (channels, )
actual = {key for key in raw if key.startswith("decoder.")}
if actual != set(expected):
raise ValueError(f"TAEH3 decoder keys mismatch: missing={set(expected) - actual}, "
f"unexpected={actual - set(expected)}")
self.weights = {}
for key, shape in expected.items():
value = raw[key]
if tuple(value.shape) != shape:
raise ValueError(f"TAEH3 weight {key} has shape {value.shape}, expected {shape}")
# Keep convolutions and features channels-last throughout execution.
value = value.transpose(0, 2, 3, 1) if value.ndim == 4 else value
self.weights[key] = mx.contiguous(value.astype(self.dtype))
mx.eval(*self.weights.values())
def _conv(self, x: Any, name: str) -> Any:
import mlx.core as mx
weight = self.weights[f"{name}.weight"]
y = mx.conv2d(x, weight, padding=weight.shape[1] // 2)
bias = self.weights.get(f"{name}.bias")
return y if bias is None else y + bias
def _chunk(self, x: Any, memory: dict[int, Any]) -> Any:
import mlx.core as mx
n, t, h, w, c = x.shape
x = mx.maximum(self._conv(mx.tanh(x.reshape(n * t, h, w, c) / 3.0) * 3.0, "decoder.1"), 0)
for indices, grow, projection, stride in (((3, 4, 5), 7, 8, 1), ((9, 10, 11), 13, 14, 2), ((15, 16, 17), 19, 20,
2)):
for index in indices:
_, h, w, c = x.shape
sequence = x.reshape(n, -1, h, w, c)
previous = memory.get(index)
if previous is None:
previous = mx.zeros_like(sequence[:, :1])
past = mx.concatenate([previous, sequence[:, :-1]], axis=1).reshape(x.shape)
# Preserve the final feature frame for the next execution chunk.
memory[index] = mx.contiguous(sequence[:, -1:])
y = mx.concatenate([x, past], axis=-1)
for layer in (0, 2, 4):
y = self._conv(y, f"decoder.{index}.conv.{layer}")
if layer != 4:
y = mx.maximum(y, 0)
x = mx.maximum(x + y, 0)
nt, h, w, c = x.shape
x = mx.broadcast_to(x.reshape(nt, h, 1, w, 1, c), (nt, h, 2, w, 2, c))
x = x.reshape(nt, h * 2, w * 2, c)
x = self._conv(x, f"decoder.{grow}.conv")
# Torch TGrow splits its channel-major output into temporal frames.
x = x.reshape(nt, h * 2, w * 2, stride, c).transpose(0, 3, 1, 2, 4)
x = self._conv(x.reshape(nt * stride, h * 2, w * 2, c), f"decoder.{projection}")
x = self._conv(mx.maximum(x, 0), "decoder.22")
nt, h, w, _ = x.shape
# Torch pixel_shuffle stores channels as (RGB, patch_y, patch_x).
x = x.reshape(nt, h, w, 3, 2, 2).transpose(0, 1, 4, 2, 5, 3)
return mx.clip(x.reshape(n, -1, h * 2, w * 2, 3), 0, 1)
def decode_ntchw(self, latents: Any, *, chunk_size: int = 5) -> Any:
"""Return NTCHW RGB in [0, 1] for H3's valid 5*k-3 latent lengths."""
import mlx.core as mx
if latents.ndim != 5 or latents.shape[2] != 24 or min(latents.shape) <= 0:
raise ValueError(f"Expected nonempty NTCHW H3 latents with 24 channels, got {latents.shape}")
if latents.shape[1] % 5 != 2:
raise ValueError("H3 latent time must be 5*k-3, for example 2, 7, or 37.")
if chunk_size < 1:
raise ValueError("TAEH3 chunk_size must be positive.")
x = latents.astype(self.dtype).transpose(0, 1, 3, 4, 2)
memory: dict[int, Any] = {}
frames = []
for start in range(0, x.shape[1], chunk_size):
decoded = self._chunk(x[:, start:start + chunk_size], memory)
# Remove three raw frames from each five-latent (20-frame) group.
keep = [i for i in range(decoded.shape[1]) if (start * 4 + i) % 20 >= 3]
decoded = decoded[:, mx.array(keep, dtype=mx.int32)]
mx.eval(decoded, *memory.values())
frames.append(decoded)
return mx.concatenate(frames, axis=1).transpose(0, 1, 4, 2, 3)
def decode_latents_taeh3_mlx(latents: np.ndarray,
*,
checkpoint_path: str | Path | None = None,
dtype: str = "fp32",
chunk_size: int = 5) -> np.ndarray:
"""Decode normalized NCTHW diffusion latents into NTHWC float RGB."""
import mlx.core as mx
if latents.ndim != 5:
raise ValueError(f"Expected NCTHW latents, got {latents.shape}")
checkpoint = ensure_taeh3_checkpoint(checkpoint_path)
decoder = MLXTAEH3Decoder(checkpoint, dtype=dtype)
output = decoder.decode_ntchw(mx.array(latents.transpose(0, 2, 1, 3, 4)), chunk_size=chunk_size)
mx.eval(output)
return np.asarray(output.astype(mx.float32)).transpose(0, 1, 3, 4, 2)
+753
View File
@@ -0,0 +1,753 @@
# SPDX-License-Identifier: Apache-2.0
# mypy: disable-error-code=no-untyped-call
"""MiniMax H3 Video Sparse Attention for the native MLX runtime.
Ports the packed-sequence VSA-H3 contract from
``fastvideo/attention/backends/video_sparse_attn_h3.py``:
- Tiles are ``[segment-pure prefix chunks] + [3D video tiles]``.
- Tile sizes 64 ``(4, 4, 4)`` and 256 ``(4, 8, 8)``.
- Per-head pooled Q/K scoring and top-k routing.
- Prefix queries are always dense; prefix keys are ``exempt`` (always kept)
or ``compete`` (FLOP-matched top-k).
- Optional dense-first steps and per-layer dense overrides.
- Trained ``to_gate_compress`` pooled-compression branch.
Execution backends:
- **reference** (``auto`` default) — grouped gather plus batched
``mx.fast.scaled_dot_product_attention``. Correctness baseline.
- **simd** — opt-in SIMD-group 8x8 matrix operations over the reference tile
map for tile size 64 and head dimension 128. Unsupported shapes and kernel
failures fall back to the reference backend.
Dense fused SDPA remains the default when VSA is disabled, the geometry is
unsupported, or a dense-only checkpoint is loaded.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from functools import cached_property
from numbers import Integral
from typing import Any, Literal
import numpy as np
from fastvideo.logger import init_logger
logger = init_logger(__name__)
VSA_H3_TILE_SHAPES: dict[int, tuple[int, int, int]] = {
256: (4, 8, 8),
64: (4, 4, 4),
}
VSA_GATE_KEY_SUFFIX = "attn.to_gate_compress.weight"
VSA_PREFIX_MODES = ("exempt", "compete")
PUBLIC_VSA_IMPLS = ("auto", "reference", "simd")
VSA_IMPLS = PUBLIC_VSA_IMPLS
# SIMD is opt-in until its dynamically routed output is accepted as the default.
_AUTO_PREFERS_SIMD = False
_REFERENCE_FULL_MASK_TILE_LIMIT = 24
_REFERENCE_FULL_MASK_MAX_ELEMENTS = 256 * 1024**2
PrefixMode = Literal["exempt", "compete"]
VSAImpl = Literal["auto", "reference", "simd"]
class DenseOnlyVSACheckpointError(ValueError):
"""VSA was requested for a checkpoint that dropped the gate weights."""
@dataclass(frozen=True)
class MiniMaxH3VSAConfig:
"""Runtime VSA knobs. Defaults preserve dense MLX H3 behavior."""
enabled: bool = False
sparsity: float = 0.9
tile_size: int = 64
prefix_mode: PrefixMode = "exempt"
dense_first_n_steps: int = 0
dense_layers: tuple[int, ...] = ()
impl: VSAImpl = "auto"
def __post_init__(self) -> None:
if not 0.0 <= self.sparsity < 1.0:
raise ValueError(f"VSA sparsity must be in [0, 1), got {self.sparsity}.")
if self.tile_size not in VSA_H3_TILE_SHAPES:
raise ValueError(f"VSA tile_size must be one of {sorted(VSA_H3_TILE_SHAPES)}, got {self.tile_size}.")
if self.prefix_mode not in VSA_PREFIX_MODES:
raise ValueError(f"VSA prefix_mode must be one of {VSA_PREFIX_MODES}, got {self.prefix_mode!r}.")
if self.impl not in VSA_IMPLS:
raise ValueError(f"VSA impl must be one of {VSA_IMPLS}, got {self.impl!r}.")
if self.dense_first_n_steps < 0:
raise ValueError(f"vsa_dense_first_n_steps must be >= 0, got {self.dense_first_n_steps}.")
if isinstance(self.dense_layers, str) or any(
not isinstance(layer, Integral) or isinstance(layer, bool) or layer < 0 for layer in self.dense_layers):
raise ValueError("VSA dense_layers must be a sequence of non-negative integer indices.")
object.__setattr__(self, "dense_layers", tuple(int(layer) for layer in self.dense_layers))
@property
def exempt(self) -> bool:
return self.prefix_mode == "exempt"
def layer_sparsity(self, layer_idx: int, step_index: int) -> float:
if not self.enabled:
return 0.0
if step_index < self.dense_first_n_steps:
return 0.0
if layer_idx in self.dense_layers:
return 0.0
return self.sparsity
@dataclass(frozen=True)
class MiniMaxH3VSAGeometry:
"""Packed-sequence tile map shared by routing, reference, and Metal paths."""
prefix_segments: tuple[int, ...]
dit_seq_shape: tuple[int, int, int]
tile_shape: tuple[int, int, int]
tile_elems: int
total_seq_length: int
num_prefix_tiles: int
num_video_tiles: int
variable_block_sizes: np.ndarray
untile_combined_index: np.ndarray
tile_partition_indices: np.ndarray
@property
def num_tiles(self) -> int:
return int(self.variable_block_sizes.shape[0])
@property
def padded_length(self) -> int:
return self.num_tiles * self.tile_elems
@property
def prefix_length(self) -> int:
return int(sum(self.prefix_segments))
@cached_property
def tile_gather_index(self):
import mlx.core as mx
index = np.full(self.padded_length, self.total_seq_length, dtype=np.int32)
index[self.untile_combined_index] = np.arange(self.total_seq_length, dtype=np.int32)
return mx.array(index)
@cached_property
def prefix_gather_index(self):
import mlx.core as mx
index = np.full(self.num_prefix_tiles * self.tile_elems, self.prefix_length, dtype=np.int32)
index[self.untile_combined_index[:self.prefix_length]] = np.arange(self.prefix_length, dtype=np.int32)
return mx.array(index)
@cached_property
def untile_index(self):
import mlx.core as mx
return mx.array(self.untile_combined_index, dtype=mx.int32)
@dataclass
class MiniMaxH3VSAStats:
"""Filled during a sparse forward so the pipeline can report achieved sparsity."""
configured_sparsity: float = 0.0
layer_sparsity: float = 0.0
tile_size: int = 64
prefix_mode: str = "exempt"
impl: str = "dense"
num_prefix_tiles: int = 0
num_video_tiles: int = 0
video_keep: float = 0.0
achieved_sparsity: float = 0.0
dense_fallback_reason: str | None = None
attention_calls: int = 0
sparse_calls: int = 0
impl_counts: dict[str, int] = field(default_factory=dict)
fallback_reasons: list[str] = field(default_factory=list)
def record(self, call: MiniMaxH3VSAStats) -> None:
"""Aggregate equally sized video-query tile maps across blocks and steps."""
self.attention_calls += 1
self.sparse_calls += int(call.layer_sparsity > 0.0)
self.impl_counts[call.impl] = self.impl_counts.get(call.impl, 0) + 1
self.impl = next(iter(self.impl_counts)) if len(self.impl_counts) == 1 else "mixed"
for name in ("layer_sparsity", "video_keep", "achieved_sparsity"):
old = getattr(self, name)
setattr(self, name, old + (getattr(call, name) - old) / self.attention_calls)
self.num_prefix_tiles = call.num_prefix_tiles
self.num_video_tiles = call.num_video_tiles
if call.dense_fallback_reason and call.dense_fallback_reason not in self.fallback_reasons:
self.fallback_reasons.append(call.dense_fallback_reason)
self.dense_fallback_reason = "; ".join(self.fallback_reasons) or None
def vsa_gate_key(block_index: int) -> str:
return f"transformer_blocks.{block_index}.{VSA_GATE_KEY_SUFFIX}"
def expected_vsa_gate_keys(num_layers: int) -> tuple[str, ...]:
return tuple(vsa_gate_key(index) for index in range(num_layers))
def is_vsa_gate_key(key: str) -> bool:
return key.endswith(VSA_GATE_KEY_SUFFIX)
def dense_only_vsa_error(checkpoint_dir: str | Any) -> DenseOnlyVSACheckpointError:
return DenseOnlyVSACheckpointError(
f"VSA was requested but {checkpoint_dir} is a dense-only MLX H3 checkpoint "
f"(no `{VSA_GATE_KEY_SUFFIX}` matrices / manifest vsa.capable). "
"Reconvert with `--include-vsa`, for example:\n"
" python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py \\\n"
" --model-root <FastH3 transformer dir> --out <new dir> --formats int6 --include-vsa")
def compute_topk(sparsity: float, num_blocks: int) -> int:
"""Blocks to keep for a sparsity level, clamped to [1, num_blocks]."""
if num_blocks <= 0:
return 0
return max(1, min(math.ceil((1.0 - sparsity) * num_blocks), num_blocks))
def parse_dense_layers(value: str | None) -> tuple[int, ...]:
if value is None or value.strip() == "":
return ()
layers = tuple(int(part.strip()) for part in value.split(",") if part.strip())
if any(layer < 0 for layer in layers):
raise ValueError(f"vsa dense layers must be non-negative, got {value!r}.")
return layers
def prefix_segments_from_layout(layout: Any, patch_size: tuple[int, int, int]) -> tuple[int, ...]:
"""Segment sizes preceding the generated-video tail, matching the PyTorch stage."""
n_text = int(layout.text_indices.shape[0])
n_cond = int(layout.num_condition_video_rows)
n_audio = int(layout.audio_indices.shape[0])
n_video = ((layout.num_video_latent_frames // patch_size[0]) * (layout.latent_height // patch_size[1]) *
(layout.latent_width // patch_size[2]))
if n_text + n_cond + n_audio + n_video != int(layout.sequence_length):
raise ValueError("VSA-H3 supports the standard [text|cond|audio|video] packing only; "
f"segments ({n_text}, {n_cond}, {n_audio}) + video {n_video} do not sum to "
f"sequence length {layout.sequence_length}.")
return n_text, n_cond, n_audio
def dit_seq_shape_from_layout(layout: Any, patch_size: tuple[int, int, int]) -> tuple[int, int, int]:
return (
int(layout.num_video_latent_frames // patch_size[0]),
int(layout.latent_height // patch_size[1]),
int(layout.latent_width // patch_size[2]),
)
def _video_tile_sizes(dit_seq_shape: tuple[int, int, int], tile_shape: tuple[int, int, int]) -> np.ndarray:
t, h, w = dit_seq_shape
ts_t, ts_h, ts_w = tile_shape
n_t, n_h, n_w = math.ceil(t / ts_t), math.ceil(h / ts_h), math.ceil(w / ts_w)
def _sizes(dim_len: int, tile: int, n_tiles: int) -> np.ndarray:
sizes = np.full((n_tiles, ), tile, dtype=np.int64)
remainder = dim_len - (n_tiles - 1) * tile
sizes[-1] = remainder if remainder > 0 else tile
return sizes
t_sizes = _sizes(t, ts_t, n_t)
h_sizes = _sizes(h, ts_h, n_h)
w_sizes = _sizes(w, ts_w, n_w)
return (t_sizes[:, None, None] * h_sizes[None, :, None] * w_sizes[None, None, :]).reshape(-1)
def _video_tile_partition_indices(dit_seq_shape: tuple[int, int, int], tile_shape: tuple[int, int, int]) -> np.ndarray:
t, h, w = dit_seq_shape
ts_t, ts_h, ts_w = tile_shape
indices = np.arange(t * h * w, dtype=np.int64).reshape(t, h, w)
chunks: list[np.ndarray] = []
for tt in range(math.ceil(t / ts_t)):
for hh in range(math.ceil(h / ts_h)):
for ww in range(math.ceil(w / ts_w)):
chunks.append(indices[tt * ts_t:min(tt * ts_t + ts_t, t), hh * ts_h:min(hh * ts_h + ts_h, h),
ww * ts_w:min(ww * ts_w + ts_w, w)].reshape(-1))
return np.concatenate(chunks, axis=0)
def _non_pad_index(variable_block_sizes: np.ndarray, tile_elems: int) -> np.ndarray:
n_win = int(variable_block_sizes.shape[0])
starts = np.arange(n_win, dtype=np.int64) * tile_elems
index_pad = starts[:, None] + np.arange(tile_elems, dtype=np.int64)[None, :]
index_mask = np.arange(tile_elems, dtype=np.int64)[None, :] < variable_block_sizes[:, None]
return index_pad[index_mask]
def validate_h3_tile_geometry(
prefix_segments: tuple[int, ...],
dit_seq_shape: tuple[int, int, int],
variable_block_sizes: np.ndarray,
untile_combined_index: np.ndarray,
tile_elems: int,
) -> None:
total = sum(prefix_segments) + math.prod(dit_seq_shape)
n_pad = int(variable_block_sizes.size) * tile_elems
sizes_min = int(variable_block_sizes.min()) if variable_block_sizes.size else 0
sizes_max = int(variable_block_sizes.max()) if variable_block_sizes.size else 0
sizes_sum = int(variable_block_sizes.sum())
if sizes_min < 1 or sizes_max > tile_elems or sizes_sum != total:
raise ValueError(f"VSA-H3 tile sizes out of bounds for prefix={prefix_segments}, video={dit_seq_shape}, "
f"tile_elems={tile_elems}: min={sizes_min}, max={sizes_max}, sum={sizes_sum}, "
f"expected sum={total}.")
if int(untile_combined_index.size) != total:
raise ValueError(f"VSA-H3 untile index has {untile_combined_index.size} entries for a packed "
f"sequence of {total} rows (prefix={prefix_segments}, video={dit_seq_shape}).")
idx_min = int(untile_combined_index.min())
idx_max = int(untile_combined_index.max())
if idx_min < 0 or idx_max >= n_pad:
raise ValueError(f"VSA-H3 untile index is not an injective map into non-pad slots: range "
f"[{idx_min}, {idx_max}] vs padded length {n_pad} "
f"(prefix={prefix_segments}, video={dit_seq_shape}).")
in_tile_offset = untile_combined_index % tile_elems
maps_into_pad = bool((in_tile_offset >= variable_block_sizes[untile_combined_index // tile_elems]).any())
if maps_into_pad or int(np.unique(untile_combined_index).size) != total:
raise ValueError(f"VSA-H3 untile index is not an injective map into non-pad slots: "
f"pad-slot hit={maps_into_pad} "
f"(prefix={prefix_segments}, video={dit_seq_shape}).")
def build_h3_tile_geometry(
prefix_segments: tuple[int, ...],
dit_seq_shape: tuple[int, int, int],
tile_size: int = 64,
) -> MiniMaxH3VSAGeometry:
"""Tile the packed sequence: segment-pure prefix chunks, then video tiles."""
tile_shape = VSA_H3_TILE_SHAPES.get(int(tile_size))
if tile_shape is None:
raise ValueError(f"VSA-H3 tile_size must be one of {sorted(VSA_H3_TILE_SHAPES)}, got {tile_size!r}")
if len(dit_seq_shape) != 3 or any(axis <= 0 for axis in dit_seq_shape):
raise ValueError(f"VSA-H3 video axes must be positive, got {dit_seq_shape}.")
tile_elems = math.prod(tile_shape)
prefix_segments = tuple(int(segment) for segment in prefix_segments if int(segment) > 0)
prefix_len = sum(prefix_segments)
prefix_sizes: list[int] = []
for segment in prefix_segments:
full, rem = divmod(segment, tile_elems)
prefix_sizes.extend([tile_elems] * full)
if rem:
prefix_sizes.append(rem)
num_prefix_tiles = len(prefix_sizes)
video_sizes = _video_tile_sizes(dit_seq_shape, tile_shape)
num_video_tiles = int(video_sizes.size)
video_indices = _video_tile_partition_indices(dit_seq_shape, tile_shape) + prefix_len
tile_partition_indices = np.concatenate(
[np.arange(prefix_len, dtype=np.int64), video_indices],
axis=0,
)
variable_block_sizes = np.concatenate(
[np.asarray(prefix_sizes, dtype=np.int64),
video_sizes.astype(np.int64)],
axis=0,
)
non_pad_index = _non_pad_index(variable_block_sizes, tile_elems)
untile_combined_index = non_pad_index[np.argsort(tile_partition_indices, kind="stable")]
validate_h3_tile_geometry(prefix_segments, dit_seq_shape, variable_block_sizes, untile_combined_index, tile_elems)
return MiniMaxH3VSAGeometry(
prefix_segments=prefix_segments,
dit_seq_shape=dit_seq_shape,
tile_shape=tile_shape,
tile_elems=tile_elems,
total_seq_length=prefix_len + math.prod(dit_seq_shape),
num_prefix_tiles=num_prefix_tiles,
num_video_tiles=num_video_tiles,
variable_block_sizes=variable_block_sizes,
untile_combined_index=untile_combined_index,
tile_partition_indices=tile_partition_indices,
)
def token_tile_and_valid(variable_block_sizes: np.ndarray, tile_elems: int) -> tuple[np.ndarray, np.ndarray]:
token_tile = np.repeat(np.arange(variable_block_sizes.size, dtype=np.int64), tile_elems)
token_valid = (np.arange(tile_elems, dtype=np.int64)[None, :] < variable_block_sizes[:, None]).reshape(-1)
return token_tile, token_valid
def build_block_mask(
scores: np.ndarray,
num_prefix_tiles: int,
num_video_tiles: int,
sparsity: float,
exempt: bool,
) -> np.ndarray:
"""scores: [..., n_tiles, n_tiles] -> bool mask, same shape.
Mirrors ``_build_block_mask`` in the PyTorch H3 backend.
"""
n_tiles = scores.shape[-1]
k_vid = compute_topk(sparsity, num_video_tiles)
if k_vid == num_video_tiles:
return np.ones_like(scores, dtype=bool)
mask = np.zeros_like(scores, dtype=bool)
if exempt or num_prefix_tiles == 0:
video_cols = scores[..., num_prefix_tiles:]
idx = np.argsort(-video_cols, axis=-1)[..., :k_vid] + num_prefix_tiles
np.put_along_axis(mask, idx, True, axis=-1)
mask[..., :num_prefix_tiles] = True
else:
k_total = min(k_vid + num_prefix_tiles, n_tiles)
idx = np.argsort(-scores, axis=-1)[..., :k_total]
np.put_along_axis(mask, idx, True, axis=-1)
mask[..., :num_prefix_tiles, :] = True
return mask
def _tile_hidden(x, geometry: MiniMaxH3VSAGeometry):
"""Gather packed rows into tiles, mapping every padding slot to one zero row."""
import mlx.core as mx
seq, heads, dim = x.shape
if seq != geometry.total_seq_length:
raise ValueError(f"VSA-H3 metadata was built for sequence length {geometry.total_seq_length}, got {seq}.")
return mx.concatenate([x, mx.zeros((1, heads, dim), dtype=x.dtype)])[geometry.tile_gather_index]
def _untile_hidden(tiled, geometry: MiniMaxH3VSAGeometry):
return tiled[geometry.untile_index]
def _pool_tiles(x, variable_block_sizes, tile_elems: int):
"""fp32 mean over each tile. x: [S_pad, H, D] -> [H, n_tiles, D]."""
import mlx.core as mx
seq_len, heads, dim = x.shape
n_tiles = seq_len // tile_elems
pooled = x.astype(mx.float32).reshape(n_tiles, tile_elems, heads, dim).sum(axis=1)
pooled = pooled / mx.array(variable_block_sizes, dtype=mx.float32)[:, None, None]
return pooled.transpose(1, 0, 2)
def _dense_sdpa(q, k, v, scale: float):
import mlx.core as mx
return mx.fast.scaled_dot_product_attention(
q.transpose(1, 0, 2)[None],
k.transpose(1, 0, 2)[None],
v.transpose(1, 0, 2)[None],
scale=scale,
)[0].transpose(1, 0, 2)
def _selected_keep(sparsity: float, num_prefix_tiles: int, num_video_tiles: int, exempt: bool) -> int:
k_vid = compute_topk(sparsity, num_video_tiles)
if k_vid == num_video_tiles:
return num_prefix_tiles + num_video_tiles
if exempt or num_prefix_tiles == 0:
return num_prefix_tiles + k_vid
return min(k_vid + num_prefix_tiles, num_prefix_tiles + num_video_tiles)
def _block_indices_from_scores(
scores,
num_prefix_tiles: int,
num_video_tiles: int,
sparsity: float,
exempt: bool,
):
"""Return (block_idx [H, n_video_tiles, k_sel], block_num [H, n_video_tiles])."""
import mlx.core as mx
n_tiles = scores.shape[-1]
heads = scores.shape[0]
k_vid = compute_topk(sparsity, num_video_tiles)
video_scores = scores[:, num_prefix_tiles:, :]
if k_vid == num_video_tiles:
idx = mx.broadcast_to(mx.arange(n_tiles, dtype=mx.int32)[None, None, :], (heads, num_video_tiles, n_tiles))
counts = mx.full((heads, num_video_tiles), n_tiles, dtype=mx.int32)
return idx, counts
if exempt or num_prefix_tiles == 0:
k_sel = num_prefix_tiles + k_vid
video_cols = video_scores[:, :, num_prefix_tiles:]
top = mx.argpartition(-video_cols, kth=k_vid - 1, axis=-1)[:, :, :k_vid].astype(mx.int32) + num_prefix_tiles
if num_prefix_tiles:
prefix = mx.broadcast_to(
mx.arange(num_prefix_tiles, dtype=mx.int32)[None, None, :],
(heads, num_video_tiles, num_prefix_tiles),
)
idx = mx.concatenate([prefix, top], axis=-1)
else:
idx = top
else:
k_sel = min(k_vid + num_prefix_tiles, n_tiles)
top = mx.argpartition(-video_scores, kth=k_sel - 1, axis=-1)[:, :, :k_sel].astype(mx.int32)
idx = top
counts = mx.full((heads, num_video_tiles), idx.shape[-1], dtype=mx.int32)
return idx, counts
def _token_mask_from_block_mask(block_mask: np.ndarray, geometry: MiniMaxH3VSAGeometry) -> np.ndarray:
token_tile, token_valid = token_tile_and_valid(geometry.variable_block_sizes, geometry.tile_elems)
# block_mask: [H, n_tiles, n_tiles]
allow = block_mask[:, token_tile][:, :, token_tile] & token_valid[None, None, :]
return allow
def _reference_token_sdpa(q_tiled, k_tiled, v_tiled, block_mask: np.ndarray, geometry: MiniMaxH3VSAGeometry,
scale: float):
import mlx.core as mx
allow = _token_mask_from_block_mask(block_mask, geometry)
return mx.fast.scaled_dot_product_attention(
mx.contiguous(q_tiled.transpose(1, 0, 2))[None],
mx.contiguous(k_tiled.transpose(1, 0, 2))[None],
mx.contiguous(v_tiled.transpose(1, 0, 2))[None],
scale=scale,
mask=mx.array(allow)[None],
)[0].transpose(1, 0, 2)
def _reference_full_mask_fits(geometry: MiniMaxH3VSAGeometry, heads: int) -> bool:
"""Bound the dense token mask by both tile count and materialized size."""
mask_elements = heads * geometry.padded_length**2
return (geometry.num_tiles <= _REFERENCE_FULL_MASK_TILE_LIMIT
and mask_elements <= _REFERENCE_FULL_MASK_MAX_ELEMENTS)
def _gather_selected_kv(k_tiles, v_tiles, block_idx, tile_elems: int):
"""Gather K/V tiles for one query-tile batch.
k_tiles/v_tiles: [H, n_tiles, tile_elems, D]
block_idx: [H, n_q, k_sel]
returns K/V: [H, n_q, k_sel * tile_elems, D]
"""
import mlx.core as mx
heads, n_q, k_sel = block_idx.shape
dim = k_tiles.shape[-1]
gathered_k = k_tiles[mx.arange(heads)[:, None, None], block_idx]
gathered_v = v_tiles[mx.arange(heads)[:, None, None], block_idx]
return (
gathered_k.reshape(heads, n_q, k_sel * tile_elems, dim),
gathered_v.reshape(heads, n_q, k_sel * tile_elems, dim),
)
def _key_valid_mask(block_idx, variable_block_sizes, tile_elems: int):
import mlx.core as mx
vbs = mx.array(variable_block_sizes, dtype=mx.int32)
selected_sizes = vbs[block_idx] # [H, n_q, k_sel]
offsets = mx.arange(tile_elems, dtype=mx.int32)
return offsets[None, None, None, :] < selected_sizes[:, :, :, None]
_REFERENCE_GATHER_TARGET_BYTES = 2 * 1024**3
def _reference_gather_query_chunk(heads: int, dim: int, k_sel: int, tile_elems: int, n_q: int) -> int:
"""Batch as many query tiles as fit in ~2 GiB of gathered BF16 K/V."""
bytes_per_query = 4 * heads * max(k_sel, 1) * tile_elems * dim
chunk = min(n_q, max(1, _REFERENCE_GATHER_TARGET_BYTES // max(bytes_per_query, 1)))
return int(chunk)
def _reference_gather_sdpa(
q_tiled,
k_tiled,
v_tiled,
block_idx,
geometry: MiniMaxH3VSAGeometry,
scale: float,
):
"""Grouped gather + batched SDPA over video query tiles.
Full-sequence gather at 720p materializes tens of GiB of selected K/V, so
query tiles are processed in memory-bounded chunks. This is the correctness
baseline and the default ``auto`` path.
"""
import mlx.core as mx
tile_elems = geometry.tile_elems
heads, dim = q_tiled.shape[1], q_tiled.shape[2]
n_tiles = geometry.num_tiles
q_tiles = q_tiled.reshape(n_tiles, tile_elems, heads, dim).transpose(2, 0, 1, 3)
k_tiles = k_tiled.reshape(n_tiles, tile_elems, heads, dim).transpose(2, 0, 1, 3)
v_tiles = v_tiled.reshape(n_tiles, tile_elems, heads, dim).transpose(2, 0, 1, 3)
q_video = q_tiles[:, geometry.num_prefix_tiles:] # [H, V, tile_elems, D]
n_q = int(q_video.shape[1])
k_sel = int(block_idx.shape[-1])
query_chunk = _reference_gather_query_chunk(heads, dim, k_sel, tile_elems, n_q)
chunks = []
for start in range(0, n_q, query_chunk):
end = min(start + query_chunk, n_q)
idx = block_idx[:, start:end, :]
n_chunk = end - start
k_sel = int(idx.shape[-1])
gathered_k, gathered_v = _gather_selected_kv(k_tiles, v_tiles, idx, tile_elems)
valid = _key_valid_mask(idx, geometry.variable_block_sizes, tile_elems)
valid = valid.reshape(heads, n_chunk, k_sel * tile_elems)
q_bh = mx.contiguous(q_video[:, start:end].reshape(heads * n_chunk, tile_elems, dim)[None])
k_bh = mx.contiguous(gathered_k.reshape(heads * n_chunk, k_sel * tile_elems, dim)[None])
v_bh = mx.contiguous(gathered_v.reshape(heads * n_chunk, k_sel * tile_elems, dim)[None])
mask = valid.reshape(1, heads * n_chunk, 1, k_sel * tile_elems)
out = mx.fast.scaled_dot_product_attention(q_bh, k_bh, v_bh, scale=scale, mask=mask)[0]
out = out.reshape(heads, n_chunk, tile_elems, dim)
mx.eval(out)
chunks.append(out)
out = mx.concatenate(chunks, axis=1) if len(chunks) > 1 else chunks[0]
return out.transpose(1, 2, 0, 3).reshape(n_q * tile_elems, heads, dim)
def _gate_compress_output(scores, v_tiled, gate_tiled, geometry: MiniMaxH3VSAGeometry):
import mlx.core as mx
v_pooled = _pool_tiles(v_tiled, geometry.variable_block_sizes, geometry.tile_elems)
attn = mx.softmax(scores, axis=-1)
out_c = attn @ v_pooled # [H, n_tiles, D]
out_c = out_c.transpose(1, 0, 2) # [n_tiles, H, D]
heads, dim = gate_tiled.shape[1], gate_tiled.shape[2]
gate_view = gate_tiled.reshape(geometry.num_tiles, geometry.tile_elems, heads, dim)
return (out_c[:, None, :, :] * gate_view).reshape(geometry.padded_length, heads, dim)
def resolve_impl(requested: VSAImpl, head_dim: int, tile_elems: int) -> str:
if requested == "reference" or (requested == "auto" and not _AUTO_PREFERS_SIMD):
return "reference"
from fastvideo.mlx_runtime.minimax_h3_vsa_simd import simd_kernel_available, simd_kernel_error
if tile_elems != 64 or head_dim != 128:
if requested == "simd":
logger.warning_once(
f"SIMD VSA requires tile 64 and head dim 128 (got tile={tile_elems} head_dim={head_dim}); "
"using reference VSA")
return "reference"
if not simd_kernel_available():
reason = simd_kernel_error() or "SIMD-group VSA kernel is unavailable"
logger.warning_once(f"SIMD VSA kernel unavailable ({reason}); using reference VSA")
return "reference"
return "simd"
def h3_vsa_attention(
query,
key,
value,
geometry: MiniMaxH3VSAGeometry,
*,
sparsity: float,
exempt: bool = True,
gate_compress=None,
impl: VSAImpl = "auto",
stats: MiniMaxH3VSAStats | None = None,
):
"""Packed ``[S, H, D]`` VSA attention. Falls back to dense SDPA when sparsity is 0."""
import mlx.core as mx
_, heads, dim = query.shape
scale = dim**-0.5
if stats is not None:
stats.configured_sparsity = sparsity
stats.layer_sparsity = sparsity
stats.tile_size = geometry.tile_elems
stats.prefix_mode = "exempt" if exempt else "compete"
stats.num_prefix_tiles = geometry.num_prefix_tiles
stats.num_video_tiles = geometry.num_video_tiles
if sparsity <= 0.0 and gate_compress is None:
if stats is not None:
stats.impl = "dense"
stats.achieved_sparsity = 0.0
stats.video_keep = geometry.num_video_tiles
return _dense_sdpa(query, key, value, scale)
q_tiled = _tile_hidden(query, geometry)
k_tiled = _tile_hidden(key, geometry)
v_tiled = _tile_hidden(value, geometry)
q_pooled = _pool_tiles(q_tiled, geometry.variable_block_sizes, geometry.tile_elems)
k_pooled = _pool_tiles(k_tiled, geometry.variable_block_sizes, geometry.tile_elems)
scores = (q_pooled @ k_pooled.transpose(0, 2, 1)) / (dim**0.5)
k_vid = compute_topk(sparsity, geometry.num_video_tiles)
if stats is not None:
stats.video_keep = k_vid if sparsity > 0.0 else geometry.num_video_tiles
stats.achieved_sparsity = (1.0 - (k_vid / geometry.num_video_tiles) if geometry.num_video_tiles else 0.0)
if sparsity <= 0.0:
out_tiled = _tile_hidden(_dense_sdpa(query, key, value, scale), geometry)
chosen = "dense"
else:
block_idx, block_num = _block_indices_from_scores(
scores,
geometry.num_prefix_tiles,
geometry.num_video_tiles,
sparsity,
exempt,
)
if stats is not None and not exempt:
stats.video_keep = float(mx.mean(mx.sum(block_idx >= geometry.num_prefix_tiles, axis=-1)).item())
stats.achieved_sparsity = 1.0 - stats.video_keep / geometry.num_video_tiles
chosen = resolve_impl(impl, dim, geometry.tile_elems)
if stats is not None and impl == "simd" and chosen != "simd":
from fastvideo.mlx_runtime.minimax_h3_vsa_simd import simd_kernel_error
stats.dense_fallback_reason = ("SIMD requires tile 64 and head dim 128"
if geometry.tile_elems != 64 or dim != 128 else simd_kernel_error())
prefix_out = (_dense_sdpa(query[:geometry.prefix_length], key, value, scale)
if geometry.prefix_length else query[:0])
n_prefix_pad = geometry.num_prefix_tiles * geometry.tile_elems
if chosen == "simd":
from fastvideo.mlx_runtime.minimax_h3_vsa_simd import disable_simd_kernel, simd_block_sparse
try:
video_tiled = simd_block_sparse(q_tiled, k_tiled, v_tiled, block_idx, block_num, geometry, scale)
# MLX compiles and executes custom Metal kernels lazily.
mx.eval(video_tiled)
video_tiled = video_tiled[n_prefix_pad:]
except Exception as error: # noqa: BLE001 - keep generation alive on kernel failure
disable_simd_kernel(error)
logger.warning_once(f"SIMD VSA kernel failed ({error}); falling back to reference gather+SDPA")
chosen = "reference"
if stats is not None:
stats.dense_fallback_reason = f"simd kernel failed: {error}"
video_tiled = _reference_gather_sdpa(q_tiled, k_tiled, v_tiled, block_idx, geometry, scale)
elif _reference_full_mask_fits(geometry, heads):
scores_np = np.array(scores, dtype=np.float32)
mask = build_block_mask(
scores_np,
geometry.num_prefix_tiles,
geometry.num_video_tiles,
sparsity,
exempt,
)
video_tiled = _reference_token_sdpa(q_tiled, k_tiled, v_tiled, mask, geometry, scale)[n_prefix_pad:]
else:
video_tiled = _reference_gather_sdpa(q_tiled, k_tiled, v_tiled, block_idx, geometry, scale)
video_tiled = video_tiled.astype(query.dtype)
prefix_tiled = mx.concatenate([
prefix_out,
mx.zeros((1, heads, dim), dtype=query.dtype),
])[geometry.prefix_gather_index]
# Prefix query tiles are dense; keep fused-SDPA prefix rows and sparse video tiles.
out_tiled = mx.concatenate([prefix_tiled, video_tiled], axis=0)
if gate_compress is not None:
gate_tiled = _tile_hidden(gate_compress, geometry)
out_tiled = out_tiled + _gate_compress_output(scores, v_tiled, gate_tiled, geometry).astype(out_tiled.dtype)
if stats is not None:
stats.impl = chosen if sparsity > 0.0 else "dense"
return _untile_hidden(out_tiled, geometry)
def geometry_is_supported(prefix_segments: tuple[int, ...], dit_seq_shape: tuple[int, int, int],
tile_size: int) -> str | None:
"""Return a fallback reason, or None when VSA can run."""
try:
build_h3_tile_geometry(prefix_segments, dit_seq_shape, tile_size)
except ValueError as error:
return str(error)
return None
@@ -0,0 +1,340 @@
# SPDX-License-Identifier: Apache-2.0
"""SIMD-group block-sparse attention for MiniMax H3 VSA.
This backend is an explicit opt-in. It supports tile size 64 and head dimension
128, evaluates the tile map selected by the reference router, and leaves
unsupported shapes to the reference implementation.
"""
from __future__ import annotations
from typing import Any
import numpy as np
_SIMD_KERNEL = None
_SIMD_KERNEL_ERROR: str | None = None
_SIMD_HEADER = """
#include <metal_stdlib>
#include <metal_simdgroup>
#include <metal_simdgroup_matrix>
using namespace metal;
METAL_FUNC void scale_rows_simd8x8(thread simdgroup_float8x8 &mat, const threadgroup float *row_scale,
threadgroup float *tmp, uint lane) {
simdgroup_store(mat, tmp, 8);
simdgroup_barrier(mem_flags::mem_threadgroup);
if (lane < 8) {
float s = row_scale[lane];
for (int c = 0; c < 8; c++) {
tmp[lane * 8 + c] *= s;
}
}
simdgroup_barrier(mem_flags::mem_threadgroup);
simdgroup_load(mat, tmp, 8);
}
"""
# One threadgroup = one (head, video query tile). 8 SIMD-groups x 32 = 256
# threads cover 64 query rows. Q is half smem (16 KiB). K/V stage 8 keys.
_SIMD_SOURCE = """
const int TILE = 64;
const int D = 128;
const int SG = 32;
const int N_SG = 8;
const int ROWS = 8;
const int KCHUNK = 8;
threadgroup float kvsmem[8 * 128];
threadgroup float score_smem[8 * 64];
threadgroup float scale_tmp[8 * 64];
threadgroup float qtile[8 * 64];
threadgroup float row_alpha[8 * 8];
uint tid = thread_position_in_threadgroup.x;
uint sid = tid / SG;
uint lane = tid % SG;
uint q_tile = thread_position_in_grid.y;
uint head = thread_position_in_grid.z;
int k_max = meta_i[4];
int n_prefix = meta_i[5];
int n_video = meta_i[6];
int S = meta_i[2];
float scale = meta_f[0];
bool active = ((int)q_tile < n_video && (int)head < (int)q_shape[0]);
int qt = n_prefix + (int)q_tile;
int q_valid = active ? vbs[qt] : 0;
int q_base_tile = (((int)head * S) + qt * TILE) * D;
threadgroup float *sg_scores = score_smem + sid * 64;
threadgroup float *sg_tmp = scale_tmp + sid * 64;
threadgroup float *sg_qtile = qtile + sid * 64;
threadgroup float *sg_alpha = row_alpha + sid * 8;
int qrow0 = (int)sid * ROWS;
thread simdgroup_float8x8 qfrag[16];
for (int kk = 0; kk < 16; kk++) {
simdgroup_barrier(mem_flags::mem_threadgroup);
int r0 = (int)lane / 8;
int c0 = (int)lane % 8;
int g0 = qrow0 + r0;
int g1 = qrow0 + r0 + 4;
float v0 = 0.0f;
float v1 = 0.0f;
if (active && g0 < q_valid) {
v0 = float(q[q_base_tile + g0 * D + kk * 8 + c0]);
}
if (active && g1 < q_valid) {
v1 = float(q[q_base_tile + g1 * D + kk * 8 + c0]);
}
sg_qtile[lane] = v0;
sg_qtile[lane + 32] = v1;
simdgroup_barrier(mem_flags::mem_threadgroup);
simdgroup_load(qfrag[kk], sg_qtile, 8);
}
thread simdgroup_float8x8 acc[16];
for (int kk = 0; kk < 16; kk++) {
acc[kk] = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
}
float row_m = -3.402823466e+38f;
float row_lse = 0.0f;
int nsel = active ? block_num[(int)head * n_video + (int)q_tile] : 0;
for (int s = 0; s < nsel; s++) {
int kt = block_idx[(((int)head * n_video) + (int)q_tile) * k_max + s];
int k_valid = vbs[kt];
int kv_base = (((int)head * S) + kt * TILE) * D;
for (int j0 = 0; j0 < TILE; j0 += KCHUNK) {
for (uint i = tid; i < (uint)(KCHUNK * D); i += N_SG * SG) {
int row = (int)i / D;
int col = (int)i - row * D;
int gtok = j0 + row;
float val = 0.0f;
if (gtok < k_valid && gtok < TILE && col < D) {
val = float(k[kv_base + gtok * D + col]);
}
kvsmem[i] = val;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
thread simdgroup_float8x8 smat = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
for (int kk = 0; kk < 16; kk++) {
simdgroup_float8x8 kmat;
simdgroup_load(kmat, (const threadgroup float*)(kvsmem + kk * 8), D, ulong2(0, 0), true);
simdgroup_multiply_accumulate(smat, qfrag[kk], kmat, smat);
}
simdgroup_store(smat, sg_scores, KCHUNK);
simdgroup_barrier(mem_flags::mem_threadgroup);
float scores[8];
float cmax = -3.402823466e+38f;
if (lane < (uint)ROWS) {
int grow = qrow0 + (int)lane;
for (int t = 0; t < KCHUNK; t++) {
int gtok = j0 + t;
float sc = -3.402823466e+38f;
if (grow < q_valid && gtok < k_valid && gtok < TILE) {
sc = sg_scores[(int)lane * KCHUNK + t] * scale;
}
scores[t] = sc;
cmax = metal::max(cmax, sc);
}
float m_new = metal::max(row_m, cmax);
float alpha = metal::exp(row_m - m_new);
row_lse *= alpha;
row_m = m_new;
sg_alpha[(int)lane] = alpha;
}
simdgroup_barrier(mem_flags::mem_threadgroup);
for (int kk = 0; kk < 16; kk++) {
scale_rows_simd8x8(acc[kk], sg_alpha, sg_tmp, lane);
}
threadgroup_barrier(mem_flags::mem_threadgroup);
for (uint i = tid; i < (uint)(KCHUNK * D); i += N_SG * SG) {
int row = (int)i / D;
int col = (int)i - row * D;
int gtok = j0 + row;
float val = 0.0f;
if (gtok < k_valid && gtok < TILE && col < D) {
val = float(v[kv_base + gtok * D + col]);
}
kvsmem[i] = val;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
float local = 0.0f;
if (lane < (uint)ROWS) {
int grow = qrow0 + (int)lane;
for (int t = 0; t < KCHUNK; t++) {
float w = 0.0f;
if (grow < q_valid) {
w = metal::exp(scores[t] - row_m);
}
sg_scores[(int)lane * KCHUNK + t] = w;
local += w;
}
row_lse += local;
}
simdgroup_barrier(mem_flags::mem_threadgroup);
thread simdgroup_float8x8 pmat;
simdgroup_load(pmat, sg_scores, KCHUNK);
for (int kk = 0; kk < 16; kk++) {
simdgroup_float8x8 vmat;
simdgroup_load(vmat, (const threadgroup float*)(kvsmem + kk * 8), D);
simdgroup_multiply_accumulate(acc[kk], pmat, vmat, acc[kk]);
}
threadgroup_barrier(mem_flags::mem_threadgroup);
}
}
if (lane < (uint)ROWS) {
sg_alpha[(int)lane] = row_lse > 0.0f ? 1.0f / row_lse : 0.0f;
}
simdgroup_barrier(mem_flags::mem_threadgroup);
for (int kk = 0; kk < 16; kk++) {
scale_rows_simd8x8(acc[kk], sg_alpha, sg_tmp, lane);
simdgroup_store(acc[kk], sg_tmp, 8);
simdgroup_barrier(mem_flags::mem_threadgroup);
if (active) {
int r0 = (int)lane / 8;
int c0 = (int)lane % 8;
int g0 = qrow0 + r0;
int g1 = qrow0 + r0 + 4;
if (g0 < TILE) {
out[q_base_tile + g0 * D + kk * 8 + c0] = (g0 < q_valid) ? T(sg_tmp[lane]) : T(0);
}
if (g1 < TILE) {
out[q_base_tile + g1 * D + kk * 8 + c0] = (g1 < q_valid) ? T(sg_tmp[lane + 32]) : T(0);
}
}
simdgroup_barrier(mem_flags::mem_threadgroup);
}
"""
def simd_kernel_error() -> str | None:
_simd_kernel()
return _SIMD_KERNEL_ERROR
def disable_simd_kernel(error: Exception) -> None:
"""Remember a failed compile or execution so subsequent blocks use reference."""
global _SIMD_KERNEL, _SIMD_KERNEL_ERROR
_SIMD_KERNEL_ERROR = str(error)
_SIMD_KERNEL = None
def simd_kernel_available() -> bool:
return _simd_kernel() is not None
def _simd_kernel() -> Any | None:
global _SIMD_KERNEL, _SIMD_KERNEL_ERROR
if _SIMD_KERNEL is not None:
return _SIMD_KERNEL
if _SIMD_KERNEL_ERROR is not None:
return None
try:
import mlx.core as mx
if not mx.metal.is_available():
_SIMD_KERNEL_ERROR = "Metal is not available in this MLX build"
return None
if not hasattr(mx.fast, "metal_kernel"):
_SIMD_KERNEL_ERROR = "mx.fast.metal_kernel is not available in this MLX build"
return None
kwargs: dict[str, Any] = {
"name": "h3_vsa_simdgroup_mma",
"input_names": ["q", "k", "v", "block_idx", "block_num", "vbs", "meta_i", "meta_f"],
"output_names": ["out"],
"source": _SIMD_SOURCE,
"header": _SIMD_HEADER,
}
try:
kernel = mx.fast.metal_kernel(**kwargs, compile_options={"math_mode": "safe"})
except TypeError:
kernel = mx.fast.metal_kernel(**kwargs)
from fastvideo.mlx_runtime.minimax_h3_vsa import build_h3_tile_geometry
geometry = build_h3_tile_geometry((1, ), (4, 4, 4), 64)
indices = mx.array([[[0, 1]]], dtype=mx.int32)
counts = mx.array([[2]], dtype=mx.int32)
# Constructing a CustomKernel does not compile it. Execute every
# supported dtype once before advertising this backend as available.
for dtype in (mx.float32, mx.float16, mx.bfloat16):
q = mx.zeros((geometry.padded_length, 1, 128), dtype=dtype)
probe = _launch_simd_block_sparse(kernel, q, q, q, indices, counts, geometry, 128**-0.5)
mx.eval(probe)
_SIMD_KERNEL = kernel
except Exception as error: # noqa: BLE001 - keep reference usable
_SIMD_KERNEL_ERROR = str(error)
_SIMD_KERNEL = None
return _SIMD_KERNEL
def simd_block_sparse(
q_tiled,
k_tiled,
v_tiled,
block_idx,
block_num,
geometry,
scale: float,
):
"""Block-sparse attention over tiled ``[S, H, D]`` using the reference tile map."""
kernel = _simd_kernel()
if kernel is None:
raise RuntimeError(_SIMD_KERNEL_ERROR or "SIMD-group VSA kernel is unavailable")
return _launch_simd_block_sparse(kernel, q_tiled, k_tiled, v_tiled, block_idx, block_num, geometry, scale)
def _launch_simd_block_sparse(kernel: Any, q_tiled: Any, k_tiled: Any, v_tiled: Any, block_idx: Any, block_num: Any,
geometry: Any, scale: float) -> Any:
import mlx.core as mx
if geometry.tile_elems != 64:
raise ValueError(f"SIMD-group VSA kernel supports tile 64 only, got {geometry.tile_elems}")
heads, dim = q_tiled.shape[1], q_tiled.shape[2]
if dim != 128:
raise ValueError(f"SIMD-group VSA kernel supports head dim 128, got {dim}")
q = mx.contiguous(q_tiled.transpose(1, 0, 2))
k = mx.contiguous(k_tiled.transpose(1, 0, 2))
v = mx.contiguous(v_tiled.transpose(1, 0, 2))
meta_i = mx.array(
[
dim,
geometry.tile_elems,
geometry.padded_length,
geometry.num_tiles,
int(block_idx.shape[-1]),
geometry.num_prefix_tiles,
geometry.num_video_tiles,
],
dtype=mx.int32,
)
meta_f = mx.array([scale], dtype=mx.float32)
call_kwargs = {
"inputs": [
q,
k,
v,
mx.contiguous(block_idx.astype(mx.int32)),
mx.contiguous(block_num.astype(mx.int32)),
mx.array(geometry.variable_block_sizes.astype(np.int32)),
meta_i,
meta_f,
],
"template": [("T", q.dtype)],
"grid": (256, geometry.num_video_tiles, heads),
"threadgroup": (256, 1, 1),
"output_shapes": [q.shape],
"output_dtypes": [q.dtype],
}
try:
outputs = kernel(**call_kwargs, init_value=0)
except TypeError:
outputs = kernel(**call_kwargs)
return outputs[0].transpose(1, 0, 2)
+428
View File
@@ -0,0 +1,428 @@
# SPDX-License-Identifier: Apache-2.0
"""Text-to-video generation for Wan2.1 and Wan2.2-TI2V (FastMetal) through the
native MLX runtime.
Scoped to each family's validated cookbook path only: text-to-video,
DMD-distilled denoising, a packed MLX DiT checkpoint, and TAEHV decode.
Refine, fast-spatial, RIFE fast mode, and prompt enrichment stay in the CLI
scripts (examples/inference/basic/mlx_wan_prompt_to_video.py and
mlx_wan22_generate.py) -- this module holds only what a resident server needs
to call repeatedly.
Every step below reuses the same helpers the CLI scripts already run (prompt
encoding, checkpoint loading, DMD scheduling, VAE decode) so these pipelines
and the scripts cannot silently drift into different implementations of the
same math. MLXWanPipeline (Wan2.1: 1.3B/14B) and MLXWan22Pipeline (Wan2.2:
5B) share the UMT5 prompt encoder and rotary-embedding builder below, since
that piece is identical across both families; everything DiT/VAE-shaped is
not, because the two are genuinely different architectures.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import json
from pathlib import Path
import time
from typing import Any
import numpy as np
from fastvideo.mlx_runtime.checkpoint_compat import (
UnsupportedMLXCheckpointError,
raise_if_unsupported_mlx_checkpoint,
)
from fastvideo.mlx_runtime.memory import cleanup_mlx, cleanup_torch_mps
from fastvideo.mlx_runtime.refine import plan_refine_resolutions
from fastvideo.utils import init_logger
logger = init_logger(__name__)
# Wan2.1's VAE compresses 4x temporally and 8x spatially; Wan2.2-TI2V's
# compresses 4x temporally and 16x spatially. The two families are not
# interchangeable -- MLXWanPipeline/MLXWan22Pipeline each guard against being
# pointed at the other's checkpoint (see _packed_dit_channels below).
_WAN21_TEMPORAL_COMPRESSION = 4
_WAN21_SPATIAL_COMPRESSION = 8
_WAN21_CHANNELS = 16
_WAN22_TEMPORAL_COMPRESSION = 4
_WAN22_SPATIAL_COMPRESSION = 16
_WAN22_CHANNELS = 48
_DEFAULT_DMD_STEPS = (1000, 757, 522)
@dataclass
class GenerationResult:
"""Everything a caller needs after one generate() call."""
video_path: str
timings: dict[str, float] = field(default_factory=dict)
peak_memory_gib: dict[str, float] = field(default_factory=dict)
def _peak_memory_gib() -> float:
"""Read MLX's peak-memory counter in GiB."""
import mlx.core as mx
return mx.get_peak_memory() / 2**30
def _resolve_wan_torch_device(device_arg: str):
"""Pick the torch device for UMT5 text encoding and TAEHV decode."""
import torch
if device_arg == "auto":
return torch.device("mps" if torch.backends.mps.is_available() else "cpu")
return torch.device(device_arg)
def _encode_wan_prompt(*, model_root: Path, prompt: str, max_sequence_length: int):
"""Encode a prompt with UMT5, padded/truncated to max_sequence_length."""
import torch
from transformers import AutoTokenizer, UMT5EncoderModel
device = _resolve_wan_torch_device("auto")
tokenizer = AutoTokenizer.from_pretrained(model_root / "tokenizer", local_files_only=True)
text_encoder = UMT5EncoderModel.from_pretrained(
model_root / "text_encoder",
torch_dtype=torch.bfloat16,
low_cpu_mem_usage=True,
local_files_only=True,
).to(device)
text_encoder.eval()
text_inputs = tokenizer(
[prompt],
padding="max_length",
max_length=max_sequence_length,
truncation=True,
add_special_tokens=True,
return_attention_mask=True,
return_tensors="pt",
)
input_ids = text_inputs.input_ids.to(device)
attention_mask = text_inputs.attention_mask.to(device)
valid_lengths = attention_mask.gt(0).sum(dim=1).long()
with torch.no_grad():
hidden_states = text_encoder(input_ids, attention_mask).last_hidden_state
hidden_states = hidden_states.to(dtype=torch.bfloat16)
trimmed = [row[:length] for row, length in zip(hidden_states, valid_lengths, strict=False)]
padded = torch.stack(
[torch.cat([row, row.new_zeros(max_sequence_length - row.size(0), row.size(1))]) for row in trimmed],
dim=0,
)
# bfloat16 has no NumPy dtype; fp32 is exact for every bf16 value.
padded = padded.float().cpu().contiguous()
del text_encoder, tokenizer, text_inputs, input_ids, attention_mask, valid_lengths
cleanup_torch_mps()
return padded
def _make_wan_rotary_embeddings(config: dict[str, Any], *, latent_frames: int, latent_height: int, latent_width: int):
"""Build the RoPE cos/sin tables the DiT's attention layers expect."""
import mlx.core as mx
import torch
from fastvideo.layers.rotary_embedding import get_rotary_pos_embed
num_heads = int(config["num_attention_heads"])
head_dim = int(config["attention_head_dim"])
patch_size = tuple(config["patch_size"])
post_patch = (
latent_frames // patch_size[0],
latent_height // patch_size[1],
latent_width // patch_size[2],
)
rope_dim_list = [head_dim - 4 * (head_dim // 6), 2 * (head_dim // 6), 2 * (head_dim // 6)]
freqs_cos, freqs_sin = get_rotary_pos_embed(
post_patch,
num_heads * head_dim,
num_heads,
rope_dim_list,
dtype=torch.float32,
rope_theta=10000,
)
return mx.array(freqs_cos.numpy()).astype(mx.float32), mx.array(freqs_sin.numpy()).astype(mx.float32)
def _packed_dit_channels(mlx_checkpoint: Path) -> int | None:
"""Read in_channels from a packed mlx_dit.json, or None if unreadable.
A best-effort check: an unpacked/diffusers-style or missing checkpoint is
left for generate() to fail on when it actually loads the weights.
"""
manifest_path = mlx_checkpoint / "mlx_dit.json"
if not manifest_path.is_file():
return None
try:
manifest = json.loads(manifest_path.read_text())
except (json.JSONDecodeError, OSError):
return None
config = manifest.get("config", manifest)
channels = config.get("in_channels")
return int(channels) if channels is not None else None
class MLXWanPipeline:
"""Text-to-video generation through the native MLX runtime (Wan2.1/FastMetal)."""
def __init__(self, *, model_root: str | Path, mlx_checkpoint: str | Path) -> None:
self.model_root = Path(model_root)
self.mlx_checkpoint = Path(mlx_checkpoint)
try:
raise_if_unsupported_mlx_checkpoint(self.mlx_checkpoint)
except UnsupportedMLXCheckpointError as error:
raise ValueError(str(error)) from error
if not (self.model_root / "tokenizer").exists() or not (self.model_root / "text_encoder").exists():
raise FileNotFoundError(f"Missing tokenizer/ or text_encoder/ under {self.model_root}.")
channels = _packed_dit_channels(self.mlx_checkpoint)
if channels == _WAN22_CHANNELS:
raise ValueError(f"{self.mlx_checkpoint} is a {channels}-channel Wan2.2-TI2V checkpoint "
"(e.g. FastMetal-5B-QAD); MLXWanPipeline only supports Wan2.1's "
f"{_WAN21_CHANNELS}-channel checkpoints (1.3B/14B). Use MLXWan22Pipeline instead.")
def generate(
self,
prompt: str,
*,
output_path: str | Path,
height: int = 480,
width: int = 832,
num_frames: int = 81,
seed: int = 0,
dmd_denoising_steps: tuple[int, ...] = _DEFAULT_DMD_STEPS,
flow_shift: float = 8.0,
fps: int = 16,
max_sequence_length: int = 512,
) -> GenerationResult:
import mlx.core as mx
import torch
from fastvideo.mlx_runtime.checkpoint import load_mlx_dit_checkpoint
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, dmd_step
from fastvideo.mlx_runtime.wan_vae import decode_latents_to_video
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
timings: dict[str, float] = {}
mx.random.seed(seed)
started = time.perf_counter()
prompt_embeds = _encode_wan_prompt(model_root=self.model_root,
prompt=prompt,
max_sequence_length=max_sequence_length)
timings["encode_s"] = time.perf_counter() - started
plan = plan_refine_resolutions(
height=height,
width=width,
num_frames=num_frames,
vae_spatial_compression=_WAN21_SPATIAL_COMPRESSION,
vae_temporal_compression=_WAN21_TEMPORAL_COMPRESSION,
enabled=False,
)
started = time.perf_counter()
mx.clear_cache()
mx.reset_peak_memory()
dit = load_mlx_dit_checkpoint(self.mlx_checkpoint, compile=True)
timings["load_s"] = time.perf_counter() - started
timings["load_peak_gib"] = _peak_memory_gib()
scheduler = FlowMatchEulerDiscreteScheduler(shift=flow_shift)
timesteps = torch.tensor(list(dmd_denoising_steps), dtype=torch.long)
dmd_schedule = MLXDMDSchedule.from_torch_scheduler(scheduler)
latents_seed = torch.Generator(device="cpu").manual_seed(seed)
latents_torch = torch.randn(
(1, int(
dit.config["in_channels"]), plan.latent_frames, plan.stage1_latent_height, plan.stage1_latent_width),
generator=latents_seed,
dtype=torch.float32,
)
latents = mx.array(latents_torch.numpy()).astype(mx.float16)
encoder_hidden_states = mx.array(prompt_embeds.numpy()).astype(mx.float16)
freqs_cis = _make_wan_rotary_embeddings(
dit.config,
latent_frames=plan.latent_frames,
latent_height=plan.stage1_latent_height,
latent_width=plan.stage1_latent_width,
)
started = time.perf_counter()
mx.reset_peak_memory()
for step_index, timestep in enumerate(timesteps):
timestep_mx = mx.array([float(timestep.item())]).astype(mx.float32)
noise_pred = dit(latents.astype(mx.float16), encoder_hidden_states, timestep_mx, freqs_cis)
is_last_step = step_index == len(timesteps) - 1
next_timestep = None if is_last_step else float(timesteps[step_index + 1].item())
renoise = None if is_last_step else mx.random.normal(latents.shape).astype(mx.float32)
latents = dmd_step(
latents=latents.astype(mx.float32),
noise_input_latent=latents.astype(mx.float32),
pred_noise=noise_pred.astype(mx.float32),
schedule=dmd_schedule,
timestep=float(timestep.item()),
next_timestep=next_timestep,
noise=renoise,
).astype(mx.float16)
mx.eval(latents)
logger.info("Wan MLX denoise step %d/%d complete", step_index + 1, len(timesteps))
timings["denoise_s"] = time.perf_counter() - started
timings["denoise_peak_gib"] = _peak_memory_gib()
latents_np = np.array(latents.astype(mx.float32))
# Free the DiT before decode -- this is a resident server, but MLX's
# unified memory still holds one heavyweight phase at a time, matching
# the CLI script's proven memory behavior on this hardware class.
del dit, latents, encoder_hidden_states, freqs_cis
cleanup_mlx()
started = time.perf_counter()
output_path = Path(output_path)
decode_latents_to_video(
latents_np,
output_path,
fps=fps,
backend="taehv",
z_dim=latents_np.shape[1],
taehv_checkpoint=None,
torch_device="auto",
)
timings["decode_s"] = time.perf_counter() - started
cleanup_torch_mps()
return GenerationResult(video_path=str(output_path),
timings=timings,
peak_memory_gib={
k: v
for k, v in timings.items() if k.endswith("_gib")
})
class MLXWan22Pipeline:
"""Text-to-video generation through the native MLX runtime (Wan2.2-TI2V/FastMetal-5B)."""
def __init__(self, *, model_root: str | Path, mlx_checkpoint: str | Path) -> None:
self.model_root = Path(model_root)
self.mlx_checkpoint = Path(mlx_checkpoint)
try:
raise_if_unsupported_mlx_checkpoint(self.mlx_checkpoint)
except UnsupportedMLXCheckpointError as error:
raise ValueError(str(error)) from error
if not (self.model_root / "tokenizer").exists() or not (self.model_root / "text_encoder").exists():
raise FileNotFoundError(f"Missing tokenizer/ or text_encoder/ under {self.model_root}.")
channels = _packed_dit_channels(self.mlx_checkpoint)
if channels is not None and channels != _WAN22_CHANNELS:
raise ValueError(f"{self.mlx_checkpoint} is a {channels}-channel checkpoint; MLXWan22Pipeline only "
f"supports Wan2.2-TI2V's {_WAN22_CHANNELS}-channel checkpoints (FastMetal-5B-QAD). "
"Use MLXWanPipeline for 1.3B/14B.")
def generate(
self,
prompt: str,
*,
output_path: str | Path,
# Defaults match the validated FastMetal-5B-QAD cookbook recipe, not
# mlx_wan22_generate.py's own argparse defaults (448x832x121), which
# were never the evidence-backed shape for this checkpoint.
height: int = 704,
width: int = 1280,
num_frames: int = 81,
seed: int = 1234,
dmd_denoising_steps: tuple[int, ...] = _DEFAULT_DMD_STEPS,
flow_shift: float = 5.0,
fps: int = 24,
max_sequence_length: int = 512,
) -> GenerationResult:
import mlx.core as mx
import torch
from fastvideo.mlx_runtime.wan22 import mlx_wan22_dit_from_mlx_checkpoint
from fastvideo.mlx_runtime.wan22_sample import sample_wan22_dmd
from fastvideo.mlx_runtime.wan_vae import decode_latents_to_video
timings: dict[str, float] = {}
mx.random.seed(seed)
started = time.perf_counter()
prompt_embeds = _encode_wan_prompt(model_root=self.model_root,
prompt=prompt,
max_sequence_length=max_sequence_length)
timings["encode_s"] = time.perf_counter() - started
plan = plan_refine_resolutions(
height=height,
width=width,
num_frames=num_frames,
vae_spatial_compression=_WAN22_SPATIAL_COMPRESSION,
vae_temporal_compression=_WAN22_TEMPORAL_COMPRESSION,
enabled=False,
)
started = time.perf_counter()
mx.clear_cache()
mx.reset_peak_memory()
dit = mlx_wan22_dit_from_mlx_checkpoint(self.mlx_checkpoint, compile=True)
timings["load_s"] = time.perf_counter() - started
timings["load_peak_gib"] = _peak_memory_gib()
latents_seed = torch.Generator(device="cpu").manual_seed(seed)
latents_torch = torch.randn(
(1, int(
dit.config["in_channels"]), plan.latent_frames, plan.stage1_latent_height, plan.stage1_latent_width),
generator=latents_seed,
dtype=torch.float32,
)
noise = mx.array(latents_torch.numpy()).astype(mx.float16)
encoder_hidden_states = mx.array(prompt_embeds.numpy()).astype(mx.float16)
freqs_cis = _make_wan_rotary_embeddings(
dit.config,
latent_frames=plan.latent_frames,
latent_height=plan.stage1_latent_height,
latent_width=plan.stage1_latent_width,
)
started = time.perf_counter()
mx.reset_peak_memory()
# sample_wan22_dmd's own re-noise seed defaults to 0 in the CLI script
# (--renoise-seed), independent of --seed; matched here rather than
# exposed as a second knob nobody overrides in the validated recipe.
latents = sample_wan22_dmd(
dit,
encoder_hidden_states,
noise,
freqs_cis,
dmd_denoising_steps=list(dmd_denoising_steps),
flow_shift=flow_shift,
warp_denoising_step=True,
seed=0,
)
timings["denoise_s"] = time.perf_counter() - started
timings["denoise_peak_gib"] = _peak_memory_gib()
latents_np = np.array(latents.astype(mx.float32))
# Free the DiT before decode, matching the CLI script's phase-memory
# policy -- the 5B DiT and the decoder are not held resident together.
del dit, latents, encoder_hidden_states, freqs_cis, noise
cleanup_mlx()
started = time.perf_counter()
output_path = Path(output_path)
decode_latents_to_video(
latents_np,
output_path,
fps=fps,
backend="taehv",
z_dim=latents_np.shape[1],
taehv_checkpoint=None,
torch_device="auto",
)
timings["decode_s"] = time.perf_counter() - started
cleanup_torch_mps()
return GenerationResult(video_path=str(output_path),
timings=timings,
peak_memory_gib={
k: v
for k, v in timings.items() if k.endswith("_gib")
})
+5 -1
View File
@@ -239,7 +239,11 @@ def maybe_load_fsdp_model(
fsdp_shard_conditions=model._fsdp_shard_conditions,
pin_cpu_memory=pin_cpu_memory)
weight_iterator = safetensors_weights_iterator(weight_dir_list, to_cpu=True)
# Host offload is already disabled on unified memory (GB10). Staging the
# 35B FastH3 DiT on CPU and then copying to CUDA doubled that working set
# and took minutes. Follow cpu_offload: read onto the accelerator.
weight_iterator = safetensors_weights_iterator(weight_dir_list, to_cpu=cpu_offload)
logger.info("Loading transformer weights with to_cpu=%s", cpu_offload)
param_names_mapping_fn = get_param_names_mapping(model.param_names_mapping)
dense_lora_patch = DenseLoRAPatch.from_adapter(
lora_path,
@@ -3,8 +3,17 @@
from __future__ import annotations
import gc
from dataclasses import dataclass
from typing import Any
import torch
from fastvideo.configs.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAEArchConfig
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEArchConfig
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.pipelines.basic.minimax_h3.stages import (
MiniMaxH3AudioDecodingStage,
MiniMaxH3ConditioningStage,
@@ -15,6 +24,37 @@ from fastvideo.pipelines.basic.minimax_h3.stages import (
)
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
logger = init_logger(__name__)
# Same split as the MLX runtime: condition, release the ~66 GB Qwen3-VL stack,
# then load DiT + VAEs. Keeping them resident together OOMs unified-memory
# boxes (GB10 / Spark) even though host offload is correctly disabled there.
_DENOISE_MODULE_NAMES = ("vae", "audio_vae", "transformer")
@dataclass(frozen=True)
class _H3VideoGeometry:
spatial_compression_ratio: int
latent_channels: int
@dataclass(frozen=True)
class _H3AudioGeometry:
sampling_rate: int
def _default_video_geometry() -> _H3VideoGeometry:
arch = MiniMaxH3VideoVAEArchConfig()
return _H3VideoGeometry(
spatial_compression_ratio=int(arch.spatial_compression_ratio),
latent_channels=int(arch.latent_channels),
)
def _default_audio_geometry() -> _H3AudioGeometry:
return _H3AudioGeometry(sampling_rate=int(MiniMaxH3AudioVAEArchConfig().sampling_rate))
class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
@@ -53,6 +93,11 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
"audio_scheduler",
]
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._ref2va = False
self._denoise_stages_ready = False
super().__init__(*args, **kwargs)
@classmethod
def get_hf_download_component_dirs(cls) -> tuple[str, ...]:
return tuple(sorted(cls._extra_config_module_map.get(name, name) for name in cls._required_config_modules))
@@ -67,18 +112,84 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
if shift is None or float(shift) != expected_shift:
raise ValueError(f"MiniMax-H3 {modality} scheduler must expose shift={expected_shift:g}, got {shift}.")
def _add_stages(self, *, ref2va: bool) -> None:
transformer = self.get_module("transformer")
vae = self.get_module("vae")
audio_vae = self.get_module("audio_vae")
scheduler = self.get_module("scheduler")
audio_scheduler = self.get_module("audio_scheduler")
def _defer_denoise_modules(self, fastvideo_args: FastVideoArgs) -> bool:
if not fastvideo_args.inference_mode or bool(getattr(fastvideo_args, "training_mode", False)):
return False
requested = fastvideo_args.h3_sequential_load
if requested is True:
return True
if requested is False:
return False
from fastvideo.pipelines import composed_pipeline_base
from fastvideo.platforms import current_platform
device = composed_pipeline_base.get_local_torch_device()
device_id = 0 if device.index is None else int(device.index)
unified = bool(current_platform.has_unified_memory(device_id))
logger.info("MiniMax-H3 sequential module load auto=%s (unified_memory=%s)", unified, unified)
return unified
def _denoise_modules_loaded(self) -> bool:
return all(self.get_module(name) is not None for name in _DENOISE_MODULE_NAMES)
def load_modules(self,
fastvideo_args: FastVideoArgs,
loaded_modules: dict[str, torch.nn.Module] | None = None) -> dict[str, Any]:
"""Load the Qwen3-VL conditioner first; defer DiT and VAEs until after encode."""
if not self._defer_denoise_modules(fastvideo_args):
return super().load_modules(fastvideo_args, loaded_modules)
if loaded_modules is not None and all(name in loaded_modules for name in _DENOISE_MODULE_NAMES):
return super().load_modules(fastvideo_args, loaded_modules)
saved = list(self.required_config_modules)
self._required_config_modules = [name for name in saved if name not in _DENOISE_MODULE_NAMES]
try:
logger.info("Loading MiniMax-H3 condition modules first: %s", self._required_config_modules)
return super().load_modules(fastvideo_args, loaded_modules)
finally:
self._required_config_modules = saved
def _load_denoise_modules(self, fastvideo_args: FastVideoArgs) -> None:
if self._denoise_modules_loaded():
return
saved = list(self.required_config_modules)
self._required_config_modules = [name for name in saved if name != "text_encoder"]
try:
logger.info("Loading MiniMax-H3 denoise modules after releasing the text encoder: %s",
[name for name in self._required_config_modules if name in _DENOISE_MODULE_NAMES])
loaded = super().load_modules(fastvideo_args, loaded_modules=self.modules)
for name, module in loaded.items():
self.add_module(name, module)
finally:
self._required_config_modules = saved
def _release_text_encoder(self) -> None:
stage = self._stage_name_mapping.get("conditioning_stage")
if stage is not None:
stage.conditioner = None
encoder = self.modules.pop("text_encoder", None)
if encoder is None:
return
logger.info("Released MiniMax-H3 text encoder after conditioning")
del encoder
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _input_vae(self) -> Any:
return self.get_module("vae") or _default_video_geometry()
def _input_audio_vae(self, *, ref2va: bool) -> Any | None:
if not ref2va:
return None
return self.get_module("audio_vae") or _default_audio_geometry()
def _add_condition_stages(self, *, ref2va: bool) -> None:
self.add_stage(
"input_preparation_stage",
MiniMaxH3InputPreparationStage(
vae=vae,
audio_vae=audio_vae if ref2va else None,
vae=self._input_vae(),
audio_vae=self._input_audio_vae(ref2va=ref2va),
ref2va=ref2va,
),
)
@@ -91,6 +202,15 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
ref2va=ref2va,
),
)
def _add_denoise_stages(self, *, ref2va: bool) -> None:
transformer = self.get_module("transformer")
vae = self.get_module("vae")
audio_vae = self.get_module("audio_vae")
scheduler = self.get_module("scheduler")
audio_scheduler = self.get_module("audio_scheduler")
if transformer is None or vae is None or audio_vae is None:
raise RuntimeError("MiniMax-H3 denoise stages require transformer, vae, and audio_vae to be loaded.")
self.add_stage(
"latent_preparation_stage",
MiniMaxH3LatentPreparationStage(
@@ -111,6 +231,35 @@ class MiniMaxH3BasePipeline(LoRAPipeline, ComposedPipelineBase):
)
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae, transformer=transformer))
self.add_stage("audio_decoding_stage", MiniMaxH3AudioDecodingStage(audio_vae=audio_vae))
self._denoise_stages_ready = True
def _add_stages(self, *, ref2va: bool) -> None:
self._ref2va = ref2va
self._add_condition_stages(ref2va=ref2va)
if self._denoise_modules_loaded():
self._add_denoise_stages(ref2va=ref2va)
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
if not self.post_init_called:
self.post_init()
if self._denoise_stages_ready:
return super().forward(batch, fastvideo_args)
logger.info("Running MiniMax-H3 condition stages before loading DiT/VAE weights")
for stage in self.stages:
batch = stage(batch, fastvideo_args)
self._release_text_encoder()
self._load_denoise_modules(fastvideo_args)
self._add_denoise_stages(ref2va=self._ref2va)
for name in (
"latent_preparation_stage",
"denoising_stage",
"video_decoding_stage",
"audio_decoding_stage",
):
batch = self._stage_name_mapping[name](batch, fastvideo_args)
return batch
class MiniMaxH3Pipeline(MiniMaxH3BasePipeline):
+9 -1
View File
@@ -5,7 +5,7 @@ import platform
import torch
from fastvideo.platforms.interface import CpuArchEnum, Platform, PlatformEnum
from fastvideo.platforms.interface import AttentionBackendEnum, CpuArchEnum, Platform, PlatformEnum
class CpuPlatform(Platform):
@@ -14,6 +14,14 @@ class CpuPlatform(Platform):
device_type = "cpu"
dispatch_key = "CPU"
@classmethod
def get_attn_backend_cls(cls, selected_backend: AttentionBackendEnum | None, head_size: int,
dtype: torch.dtype) -> str:
if selected_backend not in (None, AttentionBackendEnum.TORCH_SDPA):
raise NotImplementedError(f"{selected_backend.name} is not supported on CPU. "
"Set FASTVIDEO_ATTENTION_BACKEND to TORCH_SDPA.")
return "fastvideo.attention.backends.sdpa.SDPABackend"
@classmethod
def get_cpu_architecture(cls) -> CpuArchEnum:
"""Get the CPU architecture."""
@@ -0,0 +1,295 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan MLX serving config, request validation, and generator dispatch.
Mirrors fastvideo/tests/entrypoints/test_openai_video_client.py's MLX H3
coverage pattern. MLXWanGenerator only imports the real `mlx` package inside
_load, which runs on a worker thread and is stubbed out here -- nothing in
this file needs Apple Silicon.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from fastvideo.entrypoints.openai.mlx_wan_server import (
MLXWanGenerator,
create_mlx_wan_app,
load_config,
validate_wan_video_request,
)
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
ROOT = Path(__file__).resolve().parents[3]
CONFIG_PATH = ROOT / "examples/serving/mlx_wan21_1_3b.yaml"
CONFIG_PATH_14B = ROOT / "examples/serving/mlx_wan21_14b.yaml"
CONFIG_PATH_5B = ROOT / "examples/serving/mlx_wan22_5b.yaml"
def test_load_config_parses_the_checked_in_yaml() -> None:
config = load_config(str(CONFIG_PATH))
assert config.runtime == "mlx"
assert config.generator.model_path == "FastVideo/FastMetal-1.3B-QAD"
assert config.generator.model_root == "./FastMetal-1.3B-QAD"
assert config.generator.mlx_checkpoint == "./FastMetal-1.3B-QAD"
assert config.server.served_model_name == "fastwan21-1.3b-mlx"
def test_load_config_parses_the_14b_yaml() -> None:
config = load_config(str(CONFIG_PATH_14B))
assert config.generator.model_path == "FastVideo/FastMetal-14B-QAD"
assert config.generator.model_root == "./FastMetal-14B-QAD"
assert config.server.served_model_name == "fastwan21-14b-mlx"
def test_create_app_uses_the_14b_config_model_path_not_the_1_3b_default() -> None:
"""Regression guard: the served model must come from the config, not a
module-level constant left over from when only 1.3B was supported."""
config = load_config(str(CONFIG_PATH_14B))
app = create_mlx_wan_app(config)
assert app.state.fastvideo_args.model_path == "FastVideo/FastMetal-14B-QAD"
def test_load_config_parses_the_5b_yaml() -> None:
config = load_config(str(CONFIG_PATH_5B))
assert config.generator.model_path == "FastVideo/FastMetal-5B-QAD"
assert config.server.served_model_name == "fastwan22-5b-mlx"
assert config.default_request["sampling"]["height"] == 704
assert config.default_request["sampling"]["width"] == 1280
def test_generator_config_rejects_unknown_fields() -> None:
from fastvideo.entrypoints.openai.mlx_wan_server import MLXWanGeneratorConfig
with pytest.raises(ValidationError):
MLXWanGeneratorConfig(model_path="FastVideo/FastMetal-1.3B-QAD", model_root="x", mlx_checkpoint="y",
extra_field="not allowed")
def test_generator_config_requires_a_model_path() -> None:
from fastvideo.entrypoints.openai.mlx_wan_server import MLXWanGeneratorConfig
with pytest.raises(ValidationError):
MLXWanGeneratorConfig(model_root="x", mlx_checkpoint="y")
@pytest.mark.parametrize("model_path", ["FastVideo/FastMetal-1.3B-QAD", "FastVideo/FastMetal-14B-QAD"])
def test_generator_config_accepts_the_two_wan21_sizes(model_path) -> None:
"""1.3B and 14B share MLXWanPipeline's Wan2.1 architecture."""
from fastvideo.entrypoints.openai.mlx_wan_server import MLXWanGeneratorConfig
config = MLXWanGeneratorConfig(model_path=model_path, model_root="x", mlx_checkpoint="y")
assert config.model_path == model_path
def test_generator_config_accepts_the_wan22_5b_model() -> None:
"""FastMetal 5B is Wan2.2-TI2V; MLXWanGenerator routes it to MLXWan22Pipeline
(see test_generator_loads_the_pipeline_class_matching_model_path)."""
from fastvideo.entrypoints.openai.mlx_wan_server import MLXWanGeneratorConfig
config = MLXWanGeneratorConfig(model_path="FastVideo/FastMetal-5B-QAD", model_root="x", mlx_checkpoint="y")
assert config.model_path == "FastVideo/FastMetal-5B-QAD"
def test_generator_config_rejects_an_unregistered_model() -> None:
from fastvideo.entrypoints.openai.mlx_wan_server import MLXWanGeneratorConfig
with pytest.raises(ValidationError):
MLXWanGeneratorConfig(model_path="FastVideo/SomeOtherModel", model_root="x", mlx_checkpoint="y")
@pytest.mark.parametrize("field,value", [
("negative_prompt", "bad"),
("task", "t2va"),
("guidance_scale", 2.0),
("num_inference_steps", 10),
("seed", -1),
])
def test_validate_rejects_unsupported_request_fields(field, value) -> None:
with pytest.raises(ValueError):
validate_wan_video_request(VideoGenerationRequest(prompt="a cat", **{field: value}))
def test_validate_accepts_the_recipe_shape() -> None:
validate_wan_video_request(VideoGenerationRequest(prompt="a cat", num_inference_steps=3, guidance_scale=1.0))
def test_validate_accepts_explicit_task_t2v() -> None:
validate_wan_video_request(VideoGenerationRequest(prompt="a cat", task="t2v"))
@pytest.mark.parametrize("seed", [0, 2**32 - 1])
def test_validate_accepts_seed_boundary_values(seed) -> None:
validate_wan_video_request(VideoGenerationRequest(prompt="a cat", seed=seed))
def test_validate_rejects_seed_above_the_uint32_range() -> None:
with pytest.raises(ValueError):
validate_wan_video_request(VideoGenerationRequest(prompt="a cat", seed=2**32))
def test_create_app_builds_without_loading_the_mlx_pipeline() -> None:
"""generator_factory is lazy -- building the app must not import mlx.core."""
config = load_config(str(CONFIG_PATH))
app = create_mlx_wan_app(config)
assert app.state.served_model_name == "fastwan21-1.3b-mlx"
def test_create_app_rejects_unsupported_default_request_fields(tmp_path) -> None:
bad_config_path = tmp_path / "bad.yaml"
bad_config_path.write_text("""
runtime: mlx
generator:
model_path: FastVideo/FastMetal-1.3B-QAD
model_root: ./FastMetal-1.3B-QAD
mlx_checkpoint: ./FastMetal-1.3B-QAD
server:
served_model_name: bad
default_request:
negative_prompt: not-supported
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
""")
with pytest.raises(ValueError, match="unsupported fields"):
create_mlx_wan_app(load_config(str(bad_config_path)))
def test_create_app_rejects_a_default_request_missing_required_sampling_fields(tmp_path) -> None:
incomplete_config_path = tmp_path / "incomplete.yaml"
incomplete_config_path.write_text("""
runtime: mlx
generator:
model_path: FastVideo/FastMetal-1.3B-QAD
model_root: ./FastMetal-1.3B-QAD
mlx_checkpoint: ./FastMetal-1.3B-QAD
server:
served_model_name: incomplete
default_request:
sampling:
height: 480
width: 832
""")
with pytest.raises(ValueError, match="must set"):
create_mlx_wan_app(load_config(str(incomplete_config_path)))
def test_generator_dispatches_request_to_pipeline_on_worker_thread() -> None:
"""MLXWanGenerator must call MLXWanPipeline.generate with the request's
sampling fields, entirely off the calling thread."""
calls: dict[str, object] = {}
class _StubPipeline:
def __init__(self, **kwargs):
calls["init_kwargs"] = kwargs
def generate(self, prompt, **kwargs):
calls["prompt"] = prompt
calls["generate_kwargs"] = kwargs
return SimpleNamespace(video_path="outputs/stub.mp4")
generator_config = SimpleNamespace(model_path="FastVideo/FastMetal-1.3B-QAD", model_root="./FastMetal-1.3B-QAD",
mlx_checkpoint="./FastMetal-1.3B-QAD")
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="arm64"), \
patch("shutil.which", return_value="/usr/bin/ffmpeg"), \
patch("fastvideo.mlx_runtime.wan_pipeline.MLXWanPipeline", _StubPipeline), \
patch("fastvideo.mlx_runtime.memory.cleanup_mlx", lambda: calls.setdefault("cleaned_up", True)):
generator = MLXWanGenerator(generator_config)
try:
request = SimpleNamespace(
prompt="a red panda",
output=SimpleNamespace(output_path="outputs/out.mp4"),
sampling=SimpleNamespace(width=832, height=480, num_frames=81, seed=1024, fps=16),
)
result = generator.generate(request)
finally:
generator.shutdown()
assert result["video_path"] == "outputs/stub.mp4"
assert calls["prompt"] == "a red panda"
assert calls["generate_kwargs"] == {"output_path": "outputs/out.mp4", "width": 832, "height": 480,
"num_frames": 81, "seed": 1024, "fps": 16}
assert calls["cleaned_up"] is True
@pytest.mark.parametrize("model_path,expected_class_name", [
("FastVideo/FastMetal-1.3B-QAD", "MLXWanPipeline"),
("FastVideo/FastMetal-14B-QAD", "MLXWanPipeline"),
("FastVideo/FastMetal-5B-QAD", "MLXWan22Pipeline"),
])
def test_generator_loads_the_pipeline_class_matching_model_path(model_path, expected_class_name) -> None:
"""1.3B/14B must load MLXWanPipeline (Wan2.1); 5B must load MLXWan22Pipeline
(Wan2.2-TI2V) -- these are different architectures, not interchangeable."""
loaded: dict[str, object] = {}
class _StubWan21Pipeline:
def __init__(self, **kwargs):
loaded["class_name"] = "MLXWanPipeline"
class _StubWan22Pipeline:
def __init__(self, **kwargs):
loaded["class_name"] = "MLXWan22Pipeline"
generator_config = SimpleNamespace(model_path=model_path, model_root="x", mlx_checkpoint="y")
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="arm64"), \
patch("shutil.which", return_value="/usr/bin/ffmpeg"), \
patch("fastvideo.mlx_runtime.wan_pipeline.MLXWanPipeline", _StubWan21Pipeline), \
patch("fastvideo.mlx_runtime.wan_pipeline.MLXWan22Pipeline", _StubWan22Pipeline), \
patch("fastvideo.mlx_runtime.memory.cleanup_mlx", lambda: None):
generator = MLXWanGenerator(generator_config)
generator.shutdown()
assert loaded["class_name"] == expected_class_name
def test_generator_rejects_non_apple_silicon() -> None:
with patch("platform.system", return_value="Linux"):
with pytest.raises(RuntimeError, match="Apple Silicon"):
MLXWanGenerator(SimpleNamespace(model_root="x", mlx_checkpoint="y"))
def test_generator_rejects_intel_mac() -> None:
"""Darwin alone isn't enough -- x86_64 Macs have no Metal MLX support."""
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="x86_64"):
with pytest.raises(RuntimeError, match="Apple Silicon"):
MLXWanGenerator(SimpleNamespace(model_root="x", mlx_checkpoint="y"))
def test_generator_rejects_missing_ffmpeg() -> None:
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="arm64"), \
patch("shutil.which", return_value=None):
with pytest.raises(RuntimeError, match="ffmpeg"):
MLXWanGenerator(SimpleNamespace(model_root="x", mlx_checkpoint="y"))
def test_generator_shuts_down_its_worker_thread_when_load_fails() -> None:
"""__init__ must not leak a running thread pool if the pipeline fails to load."""
import threading
class _BrokenPipeline:
def __init__(self, **_kwargs):
raise RuntimeError("checkpoint is corrupt")
threads_before = {thread.name for thread in threading.enumerate()}
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="arm64"), \
patch("shutil.which", return_value="/usr/bin/ffmpeg"), \
patch("fastvideo.mlx_runtime.wan_pipeline.MLXWanPipeline", _BrokenPipeline):
with pytest.raises(RuntimeError, match="checkpoint is corrupt"):
MLXWanGenerator(SimpleNamespace(model_path="FastVideo/FastMetal-1.3B-QAD", model_root="x",
mlx_checkpoint="y"))
# The worker thread pool must not outlive the failed __init__ call.
threads_after = {thread.name for thread in threading.enumerate()}
assert not any("wan-mlx" in name for name in threads_after - threads_before)

Some files were not shown because too many files have changed in this diff Show More