Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions examples/serving/mlx_wan21_14b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Native MLX FastMetal 14B server. Run from the repository root after
# downloading FastVideo/FastMetal-14B-QAD (it ships a pre-packed MLX DiT,
# so model_root and mlx_checkpoint are the same directory).
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_14b.yaml
runtime: mlx
generator:
model_path: FastVideo/FastMetal-14B-QAD
model_root: ./FastMetal-14B-QAD
mlx_checkpoint: ./FastMetal-14B-QAD
server:
host: 127.0.0.1
port: 8000
served_model_name: fastwan21-14b-mlx
output_dir: outputs/mlx_wan21_14b
default_request:
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
num_inference_steps: 3
guidance_scale: 1.0
seed: 1024
23 changes: 23 additions & 0 deletions examples/serving/mlx_wan21_1_3b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Native MLX FastMetal 1.3B server. Run from the repository root after
# downloading FastVideo/FastMetal-1.3B-QAD (it ships a pre-packed MLX DiT,
# so model_root and mlx_checkpoint are the same directory).
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_1_3b.yaml
runtime: mlx
generator:
model_path: FastVideo/FastMetal-1.3B-QAD
model_root: ./FastMetal-1.3B-QAD
mlx_checkpoint: ./FastMetal-1.3B-QAD
server:
host: 127.0.0.1
port: 8000
served_model_name: fastwan21-1.3b-mlx
output_dir: outputs/mlx_wan21_1_3b
default_request:
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
num_inference_steps: 3
guidance_scale: 1.0
seed: 1024
23 changes: 23 additions & 0 deletions examples/serving/mlx_wan22_5b.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion fastvideo/entrypoints/openai/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
default_request: GenerationRequest | None = getattr(app.state, "default_request", None)

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)
Expand Down Expand Up @@ -98,7 +100,13 @@ def create_app(
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",
Expand Down Expand Up @@ -158,6 +166,8 @@ async def openai_validation_error(_request: Request, exc: RequestValidationError

app.include_router(common_router)
app.include_router(video_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)
Expand Down
214 changes: 214 additions & 0 deletions fastvideo/entrypoints/openai/mlx_wan_server.py
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Align or reject seconds before admitting the job. The shared adapter turns an explicit seconds into num_frames = seconds * fps; with both shipped defaults (16 and 24 fps), that is always 0 mod 4, while plan_refine_resolutions requires Wan frames to be 1 mod 4. A normal OpenAI-style request therefore gets queued and fails only inside generation. Please validate the merged request shape synchronously and either map duration to the nearest valid frame grid (for example seconds * fps + 1) or do not advertise seconds for this runtime.

"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.")
Comment on lines +89 to +90
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()
8 changes: 7 additions & 1 deletion fastvideo/entrypoints/openai/serving_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@


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:
...
Expand Down Expand Up @@ -47,6 +52,7 @@ 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)

Expand Down Expand Up @@ -143,4 +149,4 @@ async def shutdown(self) -> None:
await asyncio.to_thread(self._generator.shutdown)


__all__ = ["OpenAIServingEngine"]
__all__ = ["OpenAIServingEngine", "ServingGenerator"]
2 changes: 2 additions & 0 deletions fastvideo/entrypoints/openai/video_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ 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())
Expand Down
Loading
Loading