Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ef6241d4 | ||
|
|
247972271c | ||
|
|
d74475ce06 | ||
|
|
1ae39562dd | ||
|
|
26064193e2 | ||
|
|
8446fc003e | ||
|
|
a28f2bab4b | ||
|
|
f82d8be4bf | ||
|
|
8e1775183e | ||
|
|
620bc36dc4 | ||
|
|
29ff16ec96 | ||
|
|
8f9d76a80d | ||
|
|
a4d9a75e2c | ||
|
|
b2db0c0a13 | ||
|
|
6aa7d8a278 | ||
|
|
ccc9014430 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "fastvideo-openai-client-example",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate": "node video.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "7.8.0"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||