Skip to content

Commit 84e31b8

Browse files
committed
[feat] Serve FastMetal (Wan2.1) 1.3B through native MLX
1 parent 6aada00 commit 84e31b8

4 files changed

Lines changed: 628 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Native MLX FastMetal 1.3B server. Run from the repository root after
2+
# downloading FastVideo/FastMetal-1.3B-QAD (it ships a pre-packed MLX DiT,
3+
# so model_root and mlx_checkpoint are the same directory).
4+
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_1_3b.yaml
5+
runtime: mlx
6+
generator:
7+
model_path: FastVideo/FastMetal-1.3B-QAD
8+
model_root: ./FastMetal-1.3B-QAD
9+
mlx_checkpoint: ./FastMetal-1.3B-QAD
10+
server:
11+
host: 127.0.0.1
12+
port: 8000
13+
served_model_name: fastwan21-1.3b-mlx
14+
output_dir: outputs/mlx_wan21_1_3b
15+
default_request:
16+
sampling:
17+
height: 480
18+
width: 832
19+
num_frames: 81
20+
fps: 16
21+
num_inference_steps: 3
22+
guidance_scale: 1.0
23+
seed: 1024
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Serve native FastMetal (Wan2.1) MLX through the shared video-job API and playground."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
from concurrent.futures import ThreadPoolExecutor
8+
from pathlib import Path
9+
import platform
10+
import shutil
11+
import time
12+
from types import SimpleNamespace
13+
from typing import Any, Literal
14+
15+
from pydantic import BaseModel, ConfigDict, Field
16+
import uvicorn
17+
import yaml
18+
19+
from fastvideo.api.compat import explicit_request_updates, normalize_generation_request
20+
from fastvideo.api.schema import GenerationRequest
21+
from fastvideo.entrypoints.openai.api_server import create_app
22+
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
23+
24+
MODEL: Literal["FastVideo/FastMetal-1.3B-QAD"] = "FastVideo/FastMetal-1.3B-QAD"
25+
# The DMD-distilled step ladder the validated 1.3B recipe uses (fixed count,
26+
# same reason H3 MLX serving pins num_inference_steps to its own ladder size).
27+
_DMD_STEP_COUNT = 3
28+
29+
30+
class MLXWanGeneratorConfig(BaseModel):
31+
"""Where the two FastMetal checkpoint halves live on disk."""
32+
model_config = ConfigDict(extra="forbid")
33+
model_path: Literal["FastVideo/FastMetal-1.3B-QAD"] = MODEL
34+
model_root: str
35+
mlx_checkpoint: str
36+
37+
38+
class MLXWanServerConfig(BaseModel):
39+
"""Host/port/output shape for an MLX serve YAML's ``server:`` block.
40+
41+
Generic across MLX-served models -- if a second MLX server config needs
42+
the same shape, promote this (and its H3 counterpart) to one shared
43+
module instead of a third copy.
44+
"""
45+
model_config = ConfigDict(extra="forbid")
46+
host: str = "127.0.0.1"
47+
port: int = Field(default=8000, ge=1, le=65535)
48+
output_dir: str = "outputs/mlx_wan"
49+
served_model_name: str = Field(default="fastwan", min_length=1)
50+
51+
52+
class MLXWanServeConfig(BaseModel):
53+
"""Top-level ``mlx_wan_*.yaml`` shape read by --config."""
54+
model_config = ConfigDict(extra="forbid")
55+
runtime: Literal["mlx"]
56+
generator: MLXWanGeneratorConfig
57+
server: MLXWanServerConfig = Field(default_factory=MLXWanServerConfig)
58+
default_request: dict[str, Any]
59+
60+
61+
def validate_wan_video_request(request: VideoGenerationRequest) -> None:
62+
"""Reject unsupported inputs before fetching media or creating a job."""
63+
allowed = {
64+
"model",
65+
"prompt",
66+
"seed",
67+
"size",
68+
"width",
69+
"height",
70+
"fps",
71+
"num_frames",
72+
"seconds",
73+
"video_params",
74+
"task",
75+
"guidance_scale",
76+
"num_inference_steps",
77+
}
78+
unsupported = request.model_fields_set - allowed
79+
if unsupported:
80+
raise ValueError("Wan MLX serving does not support: " + ", ".join(sorted(unsupported)))
81+
if request.task not in (None, "t2v"):
82+
raise ValueError("Wan MLX serving supports task=t2v only.")
83+
if request.guidance_scale not in (None, 1.0):
84+
raise ValueError("FastMetal MLX is DMD-distilled and requires guidance_scale=1.")
85+
if request.num_inference_steps not in (None, _DMD_STEP_COUNT):
86+
raise ValueError(f"Wan MLX serving uses a fixed {_DMD_STEP_COUNT}-step DMD ladder; "
87+
f"num_inference_steps must be {_DMD_STEP_COUNT}.")
88+
if request.seed is not None and not 0 <= request.seed <= 2**32 - 1:
89+
raise ValueError("Wan MLX seed must be between 0 and 4294967295.")
90+
91+
92+
class MLXWanGenerator:
93+
"""Keep one FastMetal pipeline on one MLX thread across requests."""
94+
95+
def __init__(self, config: MLXWanGeneratorConfig) -> None:
96+
self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wan-mlx")
97+
try:
98+
self._pipeline = self._worker.submit(self._load, config).result()
99+
except BaseException:
100+
self._worker.shutdown(wait=True)
101+
raise
102+
103+
@staticmethod
104+
def _load(config: MLXWanGeneratorConfig):
105+
"""Load the pipeline; must run on the MLX worker thread."""
106+
if platform.system() != "Darwin" or platform.machine() != "arm64":
107+
raise RuntimeError("Wan MLX serving requires an Apple Silicon Mac.")
108+
if shutil.which("ffmpeg") is None:
109+
raise RuntimeError("Install ffmpeg before starting the Wan MLX server.")
110+
from fastvideo.mlx_runtime.wan_pipeline import MLXWanPipeline
111+
112+
return MLXWanPipeline(
113+
model_root=Path(config.model_root).expanduser(),
114+
mlx_checkpoint=Path(config.mlx_checkpoint).expanduser(),
115+
)
116+
117+
def generate(self, request: GenerationRequest) -> dict[str, Any]:
118+
"""Run one generation on the MLX worker thread; block until it finishes."""
119+
return self._worker.submit(self._generate, request).result()
120+
121+
def _generate(self, request: GenerationRequest) -> dict[str, Any]:
122+
"""The actual pipeline call; must run on the MLX worker thread."""
123+
started = time.perf_counter()
124+
result = self._pipeline.generate(
125+
request.prompt,
126+
output_path=request.output.output_path,
127+
width=request.sampling.width,
128+
height=request.sampling.height,
129+
num_frames=request.sampling.num_frames,
130+
seed=request.sampling.seed,
131+
fps=request.sampling.fps,
132+
)
133+
return {"video_path": str(result.video_path), "generation_time": time.perf_counter() - started}
134+
135+
def shutdown(self) -> None:
136+
"""Release the pipeline and stop the MLX worker thread."""
137+
138+
def release():
139+
self._pipeline = None
140+
from fastvideo.mlx_runtime.memory import cleanup_mlx
141+
142+
cleanup_mlx()
143+
144+
try:
145+
self._worker.submit(release).result()
146+
finally:
147+
self._worker.shutdown(wait=True)
148+
149+
150+
def load_config(path: str) -> MLXWanServeConfig:
151+
"""Parse a Wan MLX serve YAML into its typed config."""
152+
with open(path, encoding="utf-8") as source:
153+
return MLXWanServeConfig.model_validate(yaml.safe_load(source))
154+
155+
156+
def create_mlx_wan_app(config: MLXWanServeConfig):
157+
"""Build the FastAPI app for a validated Wan MLX serve config."""
158+
request = normalize_generation_request(config.default_request)
159+
explicit = explicit_request_updates(request)
160+
supported = {"width", "height", "num_frames", "fps", "seed", "num_inference_steps", "guidance_scale"}
161+
if set(explicit) - supported:
162+
raise ValueError("Wan MLX default_request contains unsupported fields: " +
163+
", ".join(sorted(set(explicit) - supported)))
164+
required = {"width", "height", "num_frames", "fps"}
165+
if required - set(explicit):
166+
raise ValueError("Wan MLX default_request must set: " + ", ".join(sorted(required - set(explicit))))
167+
validate_wan_video_request(VideoGenerationRequest(prompt="validate config", **explicit))
168+
# Transport admission uses the registered Wan family, not CUDA engine options.
169+
args = SimpleNamespace(model_path=MODEL,
170+
lora_path=None,
171+
lora_nickname="default",
172+
lora_strength=1.0,
173+
override_pipeline_cls_name=None)
174+
from fastvideo.entrypoints.openai.request_adapter import build_generation_request
175+
176+
build_generation_request("config-check",
177+
VideoGenerationRequest(prompt="validate config"),
178+
args,
179+
served_model_name=config.server.served_model_name,
180+
output_dir=config.server.output_dir,
181+
default_request=request)
182+
return create_app(
183+
args,
184+
config.server.output_dir,
185+
request,
186+
config.server.served_model_name,
187+
generator_factory=lambda: MLXWanGenerator(config.generator),
188+
video_request_validator=validate_wan_video_request,
189+
runtime="mlx",
190+
)
191+
192+
193+
def main() -> None:
194+
"""CLI entrypoint: python -m fastvideo.entrypoints.openai.mlx_wan_server --config ..."""
195+
parser = argparse.ArgumentParser(description=__doc__)
196+
parser.add_argument("--config",
197+
required=True,
198+
help="Wan MLX serving YAML; paths are relative to the working directory")
199+
args = parser.parse_args()
200+
config = load_config(args.config)
201+
uvicorn.run(create_mlx_wan_app(config), host=config.server.host, port=config.server.port)
202+
203+
204+
if __name__ == "__main__":
205+
main()

0 commit comments

Comments
 (0)