modelbeast/server/operators/wan_video/run.py
John King bc8f5e7e23 fix(wan_video): replace ComfyUI/torch-MPS path with native mlx-video
The ComfyUI path produced structurally-valid but visually garbage MP4s on
every job from 2026-07-17 onward while still reporting success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:51:35 +10:00

141 lines
5.5 KiB
Python

"""Wan 2.2 TI2V-5B text/image→video via mlx-video (native MLX, Apple Silicon).
Replaces the previous ComfyUI/PyTorch-MPS path, which produced structurally-valid
but visually garbage MP4s on every job from 2026-07-17 onward and still reported
success. Benchmarked 2026-08-02 at identical settings (832x480, 49f, 20 steps,
cfg 5.0, seed 1234): MLX q8 132s and clean, ComfyUI fp16 216s and garbage.
Same operator id, same params, same output contract (outdir/wan_{seed}.mp4) — the
queue and any downstream consumer see no change. Weights are the q8 MLX conversion
in vendor/mlx-video-models; the generator holds them in unified memory itself, so
there is no resident-server handshake and no PYTORCH_MPS_HIGH_WATERMARK_RATIO
tuning (MLX manages its own allocation).
"""
import argparse
import json
import os
import random
import subprocess
import sys
import time
from pathlib import Path
# job env has a minimal PATH; we shell out to ffprobe for output validation
os.environ["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + os.environ.get("PATH", "")
ROOT = Path(__file__).resolve().parents[3]
GEN = ROOT / "venvs" / "mlxvideo" / "bin" / "mlx_video.wan_2.generate"
MODEL_DIR = ROOT / "vendor" / "mlx-video-models" / "Wan2.2-TI2V-5B-mlx-q8"
FPS = 24 # Wan 2.2
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
outdir = Path(a.outdir)
prompt = (p.get("prompt") or "").strip()
if not prompt:
print("ERROR: prompt is required")
sys.exit(1)
if not GEN.exists():
print(f"ERROR: mlx-video not installed at {GEN}. Run scripts/install_mlx_video.sh")
sys.exit(1)
if not (MODEL_DIR / "model.safetensors").exists():
print(f"ERROR: MLX weights missing at {MODEL_DIR} — see manifest description")
sys.exit(1)
length = int(p.get("length", 49))
if length % 4 != 1:
length = (length // 4) * 4 + 1 # model requires 4n+1 frames
width, height = int(p.get("width", 832)), int(p.get("height", 480))
seed = int(p.get("seed", -1))
if seed < 0:
seed = random.randint(0, 2**31 - 1)
start_image = a.input[0] if a.input else None
if start_image and not Path(start_image).exists():
print(f"ERROR: input image not found: {start_image}")
sys.exit(1)
out_mp4 = outdir / f"wan_{seed}.mp4"
cmd = [
str(GEN),
"--model-dir", str(MODEL_DIR),
"--prompt", prompt,
"--negative-prompt", p.get(
"negative", "blurry, distorted, low quality, static image, watermark, text"),
"--width", str(width),
"--height", str(height),
"--num-frames", str(length),
"--steps", str(int(p.get("steps", 20))),
"--guide-scale", str(float(p.get("cfg", 5.0))),
"--seed", str(seed),
"--output-path", str(out_mp4),
]
if start_image:
cmd += ["--image", start_image]
print(f"seed={seed} mode={'i2v' if start_image else 't2v'} "
f"{width}x{height}x{length}f backend=mlx-q8", flush=True)
t0 = time.time()
r = subprocess.run(cmd, capture_output=True, text=True)
# NOTE: do not gate on the exit code alone. Under memory pressure the generator
# has been observed finishing the render, writing a complete and valid MP4, and
# only then being SIGKILLed during interpreter teardown (rc=-9, "leaked
# semaphore" warning). Judge the artefact on disk first; the exit code is only
# authoritative when there is nothing usable to inspect.
# Echo the generator's own stage timings (T5 / denoise / VAE) into the job log.
import re
ANSI = re.compile(r"\x1b\[[0-9;]*m") # generator colourises; job logs are plain text
for line in (r.stdout or "").splitlines():
s = ANSI.sub("", line).strip()
if any(k in s for k in ("T5 encoding", "Models loaded", "Denoising:", "VAE decode", "Total time")):
if "it/s" not in s and "s/it" not in s: # skip the tqdm bar
print(f" {s}", flush=True)
# --- output validation ---------------------------------------------------
# The old ComfyUI path marked six garbage jobs "done" because nothing here ever
# checked the result. These are structural checks only (a real content-quality
# check would need a perceptual model and would risk false failures on
# legitimately flat footage) — but they do catch the silent-empty-output case.
if not out_mp4.exists() or out_mp4.stat().st_size < 50_000:
print(f"ERROR: no usable video produced at {out_mp4}")
if r.returncode != 0:
print(f" mlx-video exited {r.returncode}")
print((r.stderr or r.stdout)[-1200:])
sys.exit(1)
probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
"-show_entries", "stream=width,height,nb_read_packets", "-of", "csv=p=0", str(out_mp4)],
capture_output=True, text=True)
if probe.returncode != 0:
print(f"ERROR: output is not a decodable video: {probe.stderr[-400:]}")
sys.exit(1)
try:
gw, gh, gframes = (int(x) for x in probe.stdout.strip().split(",")[:3])
except ValueError:
print(f"ERROR: could not parse ffprobe output: {probe.stdout!r}")
sys.exit(1)
if (gw, gh) != (width, height):
print(f"ERROR: dimension mismatch — asked {width}x{height}, got {gw}x{gh}")
sys.exit(1)
if gframes < length:
print(f"ERROR: truncated output — asked {length} frames, got {gframes}")
sys.exit(1)
if r.returncode != 0:
print(f"note: output validated OK despite generator exit {r.returncode} "
f"(killed at teardown, render was complete)", flush=True)
print(f"done: {gframes} frames @ {gw}x{gh}{out_mp4.name} "
f"in {time.time() - t0:.1f}s", flush=True)