unsloth/scripts/video_quality.py
Daniel Han f776ce30a5
Video: make MiniMax-H3's Diffusers path fast by default (#8320)
* Video: make MiniMax-H3's Diffusers path fast by default

MiniMax-H3 on the Diffusers path was the slowest thing Studio ships. The same
960x544, 124-frame, 8-step job took 70-207 seconds a generation on a B200 with
nothing else resident, and the spread is the tell: every component was in the
ComponentsManager offload rotation, so the time was weight traffic, not maths.
Three separate things caused it, and all three were decisions rather than
limits.

1. The conditioner. H3 reads hidden_states[50] out of a 64-layer Qwen3-VL and
   never calls lm_head, but the released encoder is 66.7 GB of bfloat16 and
   transformers has no early exit, so the default paid 64 layers of compute and
   moved 66.7 GB across the offload boundary to read layer 50. The hosted
   quantized conditioner is 27.1 GB over 50 layers and computes the same state.
   It already existed and was already loadable, but only behind an explicit
   text_encoder_quant, which the video page never sends. An unset request now
   resolves to it.

2. The denoiser's placement. With the conditioner at 27.1 GB the released
   66.3 GB denoiser fits alongside it, so it is now taken out of the offload
   rotation and pinned when the free VRAM measured at load time holds it plus
   the conditioner, the VAEs and a frames-aware activation headroom. A module
   that moves every forward also cannot be compiled, so this is what unlocks 3.

3. The speed layer. apply_speed_optims and select_attention_backend live below
   the modular dispatch in load_pipeline, which returns before reaching them, so
   every H3 load reported speed_optims [] and attention_backend null: no
   channels_last VAE, no cudnn.benchmark, no backend pinning, no compile.
   supports_torch_compile was also False for the family. Both are fixed. The
   compile is engaged only over a resident denoiser: inside a full offload
   rotation it measured slower than eager, so that case keeps the lossless tier.

Measured on a B200, 960x544x124, 8 steps, guidance 1.0, seed 11, warm:

  free VRAM     before            after
  191 GB        70.1 / 114.6 s    12.84 / 12.83 s
  123 GB        83.6 / 176.6 s    45.9 / 42.1 s
  80 GB         119.1 / 136.2 s   90.0 / 99.5 s

Quality, same harness (scripts/video_quality.py), at H3's own 30-step default
against the released dense bfloat16 components as reference: mean SSIM 0.9504,
PSNR 33.0 dB, temporal deviation 0.042, no NaN, no black frames. The reference
compared against itself scores 0.9935, so that is a small perturbation of the
same sample, and the frames are visually indistinguishable.

What is deliberately NOT defaulted: the hosted pre-quantized denoisers. They are
much faster still (11.3 s warm, and they run in 58 GB) and they produce no NaN,
no black frames and no visible degradation, but at 30 steps they re-roll the
sample: mean SSIM 0.49 for int8 and 0.43 for fp8 against that same 0.9935
ceiling. Different is not the same as worse, but nothing available here can tell
those apart, so they stay opt-in and the default gets its speed from placement
and compilation, which change no weights at all.

The new default is CUDA-only. Apple Silicon keeps the engine it has today: the
modular loader cannot run there at all, because ComponentsManager's auto CPU
offload needs mem_get_info on the execution device and torch.mps has none.
CPU-only hosts keep the native sd.cpp engine, untouched. torch.compile now also
asks whether inductor can actually run in this process before engaging: the
three Studio workers already refuse it when Triton is missing on Windows, but
the diffusion backends run in the server process, which those gates never reach.

* Close two gaps where the new tests did not cover the guard they name

Mutation-checked the additions and two survived, both in the same shape: the
test exercises a helper the guard uses, never the guard itself.

compile_eligible could lose its torch_compile_runtime_available() call
entirely and every test still passed. The two assertions that looked like they
covered it read compile_eligible(...) is False without stubbing torch, and
without the stub the dtype check makes it return False for every input, so
they held whatever the gate did. Stub torch and add the positive control, so
the False lines have something to be false against.

The pin decision had the same problem the other way round: the test named
"pinned only when it actually fits" only drove the sizing helper, and the
comparison that authorises the pin lived inline in the modular load, where
nothing reached it. Changing it to `if True` left the suite green. Lifted it
to _h3_dense_denoiser_fits and asserted it directly, including the boundary
and the denoiser-alone case, since a pin that should not have happened is an
OOM rather than a slow generation.

Six mutations now fail: the conditioner default, the runtime gate, the
TORCHDYNAMO_DISABLE branch, the win32 branch, and both halves of the fit test.
511 passed across the video, speed and H3 suites.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments this branch added

* Fence the H3 placement, honour speed=off, and reach the dense conditioner

Three things the new default got wrong on the way in.

The dense denoiser pin is a speed optimisation by its own reasoning, so an
explicit speed=off has to decline it. Taking the denoiser out of the offload
rotation trades the ability to budget it against the requested frame count for
throughput, and "off" is the one request that says do not make that trade. The
speed profile is now resolved above the placement so the pin can read it. The
pre-quantized pin is not gated the same way: there it is correctness, a torchao
module does not survive the mid-block move.

load_components spends minutes building ~145 GB, and everything after it either
moves weights onto the card or mutates process-wide backend flags. The
conventional placement path fences on the load token for exactly that reason;
this one did not, so a cancelled or superseded worker resumed there and put a
66.3 GB denoiser next to a model a replacement load already owned. The next
check was the state commit, which is after the placement it is meant to
prevent.

An omitted text_encoder_quant now selects the hosted INT8 conditioner, which
makes none/off the only way to ask for the released bfloat16 one, and neither
spelling could be said. VideoLoadRequest accepted only the four schemes, and
normalize_te_quant raised on "off" and "auto" behind it, so the bfloat16
reference configuration was unreachable through the API and no comparison
against it could be run. Both gates now pass the opt-out through to the
tri-state, which already read it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-10 04:31:09 -07:00

509 lines
19 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Video quality-vs-cost harness for the Studio video backend.
The video analogue of scripts/diffusion_quality.py: hold the prompt + seed +
shape fixed, render one clip with a high-fidelity reference configuration
(default the family's BF16 artifact), then render the same clip with each
candidate configuration (a GGUF quant, a dense torchao quant, a speed profile,
a step cache) and measure how far the output drifts from the reference.
Per candidate it reports:
- mean PSNR / SSIM over evenly sampled frames (pixel + structural fidelity),
- a temporal-consistency deviation: the relative error between the reference's
and the candidate's frame-to-frame motion-energy series, which catches
flicker/juddering that per-frame SSIM alone can miss,
- black-frame and NaN collapse checks (the failure mode quant bugs actually
produce, per the image backend's qwen fp8 incident),
- an audio check for families that generate sound (LTX-2): RMS ratio vs the
reference and a silence trip-wire,
- wall time per generate and peak VRAM.
Verdict bands map the standing accuracy budget: a candidate that keeps mean
SSIM at or above 0.75 PASSes (a ~25 percent structural drift is acceptable for
a large speed/memory win), 0.50-0.75 WARNs, and below 0.50 or any black/NaN/
silence collapse FAILs regardless of how fast it is.
Runtime-budgeted: ONE short clip per candidate (default 33 frames at 480p-class
sizes) so a full family sweep stays in minutes, not hours. Metrics are pure
numpy; torch / diffusers / the backend load lazily so --help and --selftest run
on a host without them.
Examples:
# CPU metric sanity check (no GPU, no model):
python scripts/video_quality.py --selftest
# LTX-2.3 GGUF quants against the BF16 GGUF reference:
CUDA_VISIBLE_DEVICES=1 python scripts/video_quality.py \\
--model unsloth/LTX-2.3-GGUF --model-kind gguf \\
--reference "gguf_filename=distilled-1.1/ltx-2.3-22b-distilled-1.1-BF16.gguf" \\
--candidates "gguf_filename=distilled-1.1/ltx-2.3-22b-distilled-1.1-Q8_0.gguf" \\
"gguf_filename=distilled-1.1/ltx-2.3-22b-distilled-1.1-UD-Q4_K_M.gguf" \\
--steps 8 --guidance 1.0 --out-dir outputs/video_quality/ltx23
# Wan2.2-5B dense int8 + speed profiles against plain bf16:
CUDA_VISIBLE_DEVICES=1 python scripts/video_quality.py \\
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \\
--reference "" \\
--candidates "transformer_quant=int8" "speed_mode=max" \\
--steps 20 --out-dir outputs/video_quality/wan5b
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
from typing import Any, Optional
_BACKEND_ROOT = Path(__file__).resolve().parent.parent / "studio" / "backend"
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
DEFAULT_PROMPT = (
"a golden retriever puppy runs through shallow ocean waves at sunset, "
"splashing water, cinematic, camera tracking sideways"
)
# Finite PSNR (dB) cap for an identical clip, matching scripts/diffusion_quality.py.
_PERFECT_MATCH_PSNR = 100.0
# ── frame metrics (pure numpy; frames are uint8 HxWx3 arrays) ────────────────
def _gray(frame: Any) -> Any:
import numpy as np
f = np.asarray(frame, dtype = np.float64)
return f @ np.array([0.299, 0.587, 0.114])
def frame_psnr(a: Any, b: Any) -> float:
import numpy as np
a64 = np.asarray(a, dtype = np.float64)
b64 = np.asarray(b, dtype = np.float64)
if a64.shape != b64.shape:
return 0.0
mse = float(((a64 - b64) ** 2).mean())
if mse == 0.0:
return math.inf
return 20.0 * math.log10(255.0) - 10.0 * math.log10(mse)
def _box_mean(x: Any, w: int) -> Any:
import numpy as np
r = w // 2
xp = np.pad(x, r, mode = "edge")
ii = np.cumsum(np.cumsum(xp, axis = 0), axis = 1)
ii = np.pad(ii, ((1, 0), (1, 0)), mode = "constant")
h, wd = x.shape
total = ii[w : h + w, w : wd + w] - ii[0:h, w : wd + w] - ii[w : h + w, 0:wd] + ii[0:h, 0:wd]
return total / float(w * w)
def frame_ssim(
a: Any,
b: Any,
window: int = 7,
) -> float:
"""Pure numpy box-window SSIM on luminance (Wang et al. constants); identical
math to scripts/diffusion_quality.py so image and video budgets compare."""
ga, gb = _gray(a), _gray(b)
if ga.shape != gb.shape:
return 0.0
c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
mu_a, mu_b = _box_mean(ga, window), _box_mean(gb, window)
mu_a2, mu_b2, mu_ab = mu_a * mu_a, mu_b * mu_b, mu_a * mu_b
var_a = _box_mean(ga * ga, window) - mu_a2
var_b = _box_mean(gb * gb, window) - mu_b2
cov_ab = _box_mean(ga * gb, window) - mu_ab
ssim_map = ((2 * mu_ab + c1) * (2 * cov_ab + c2)) / (
(mu_a2 + mu_b2 + c1) * (var_a + var_b + c2)
)
return float(ssim_map.mean())
def motion_energy(frames: Any) -> list[float]:
"""Mean absolute frame-to-frame luminance difference, one value per frame
transition. The temporal signature of the clip: flicker inflates it, frozen
or smeared motion deflates it."""
import numpy as np
grays = [_gray(f) for f in frames]
return [float(np.abs(grays[i + 1] - grays[i]).mean()) for i in range(len(grays) - 1)]
def temporal_deviation(ref_frames: Any, cand_frames: Any) -> float:
"""Relative L1 error between the two motion-energy series (0 = identical
temporal behaviour). Series lengths must match (same frame count)."""
ref_series = motion_energy(ref_frames)
cand_series = motion_energy(cand_frames)
if len(ref_series) != len(cand_series) or not ref_series:
return math.inf
denom = sum(abs(v) for v in ref_series) + 1e-6
return sum(abs(r - c) for r, c in zip(ref_series, cand_series)) / denom
def clip_metrics(
ref_frames: Any,
cand_frames: Any,
sample_count: int = 5,
) -> dict[str, Any]:
"""All frame metrics for one candidate clip vs the reference clip."""
import numpy as np
# A truncated candidate is gated FAIL, not prefix-compared: good early frames would mask the missing tail.
ref_count, cand_count = len(ref_frames), len(cand_frames)
frame_count_mismatch = ref_count != cand_count
n = min(ref_count, cand_count)
if n == 0:
# An empty/corrupt decode must gate as FAIL, not crash the whole run.
return {
"frames_compared": 0,
"ref_frame_count": ref_count,
"cand_frame_count": cand_count,
"frame_count_mismatch": frame_count_mismatch,
"psnr_mean": 0.0,
"ssim_mean": 0.0,
"temporal_deviation": math.inf,
"min_luma": 0.0,
"has_nan": True,
}
idx = sorted({int(round(i * (n - 1) / max(1, sample_count - 1))) for i in range(sample_count)})
psnrs = [min(frame_psnr(ref_frames[i], cand_frames[i]), _PERFECT_MATCH_PSNR) for i in idx]
ssims = [frame_ssim(ref_frames[i], cand_frames[i]) for i in idx]
lumas = [float(_gray(cand_frames[i]).mean() / 255.0) for i in idx]
has_nan = any(
bool(np.isnan(np.asarray(f, dtype = np.float64)).any()) for f in (cand_frames[i] for i in idx)
)
return {
"frames_compared": len(idx),
"ref_frame_count": ref_count,
"cand_frame_count": cand_count,
"frame_count_mismatch": frame_count_mismatch,
"psnr_mean": sum(psnrs) / len(psnrs),
"ssim_mean": sum(ssims) / len(ssims),
"temporal_deviation": temporal_deviation(ref_frames[:n], cand_frames[:n]),
"min_luma": min(lumas),
"has_nan": has_nan,
}
def audio_metrics(ref_audio: Optional[Any], cand_audio: Optional[Any]) -> dict[str, Any]:
"""RMS comparison for families with sound. None audio on both sides is fine;
losing the track (or emitting silence) when the reference has one is not."""
import numpy as np
def _rms(a: Any) -> Optional[float]:
if a is None:
return None
arr = np.asarray(a, dtype = np.float64)
return float(np.sqrt((arr**2).mean())) if arr.size else 0.0
ref_rms, cand_rms = _rms(ref_audio), _rms(cand_audio)
# NaN compares False against any threshold, so call it out: a NaN track is a collapse.
silent_collapse = (
ref_rms is not None
and ref_rms >= 1e-3
and (cand_rms is None or math.isnan(cand_rms) or cand_rms < 1e-4)
)
return {"ref_rms": ref_rms, "cand_rms": cand_rms, "silent_collapse": silent_collapse}
def verdict(metrics: dict[str, Any], audio: dict[str, Any]) -> str:
"""PASS / WARN / FAIL per the standing accuracy budget (~25 percent structural
drift acceptable, 50 percent or a collapse never)."""
if (
metrics["has_nan"]
or metrics.get("frame_count_mismatch")
or metrics["min_luma"] < 0.02
or audio.get("silent_collapse")
):
return "FAIL"
if metrics["ssim_mean"] < 0.50 or metrics["temporal_deviation"] > 1.0:
return "FAIL"
if metrics["ssim_mean"] < 0.75 or metrics["temporal_deviation"] > 0.5:
return "WARN"
return "PASS"
# ── mp4 decode (PyAV, same dependency the backend encodes with) ─────────────
def decode_mp4(mp4_bytes: bytes, workdir: Path, name: str) -> tuple[list[Any], Optional[Any]]:
"""Frames (uint8 arrays) + mono audio samples (float array or None) from bytes."""
import av
import numpy as np
path = workdir / f"{name}.mp4"
path.write_bytes(mp4_bytes)
container = av.open(str(path))
frames = [f.to_ndarray(format = "rgb24") for f in container.decode(container.streams.video[0])]
audio = None
if container.streams.audio:
container.close()
container = av.open(str(path))
chunks = [c.to_ndarray() for c in container.decode(container.streams.audio[0])]
if chunks:
audio = np.concatenate([c.reshape(c.shape[0], -1).mean(axis = 0) for c in chunks])
container.close()
return frames, audio
# ── configuration plumbing ───────────────────────────────────────────────────
def parse_spec(spec: str) -> dict[str, str]:
"""'k=v;k=v' (or space-free 'k=v,k=v') -> dict; empty string -> {} (pure base)."""
out: dict[str, str] = {}
for part in spec.replace(",", ";").split(";"):
part = part.strip()
if not part:
continue
if "=" not in part:
raise ValueError(f"Bad candidate spec fragment '{part}' (expected key=value)")
key, value = part.split("=", 1)
out[key.strip()] = value.strip()
return out
def spec_label(spec: dict[str, str]) -> str:
if not spec:
return "base"
return ",".join(
f"{k}={Path(v).name if k == 'gguf_filename' else v}" for k, v in sorted(spec.items())
)
def run_config(
backend: Any, args: Any, spec: dict[str, str], workdir: Path, name: str
) -> dict[str, Any]:
"""Load per spec, generate the fixed clip, unload. Returns frames/audio/cost."""
import torch
load_kwargs: dict[str, Any] = {
"gguf_filename": spec.get("gguf_filename"),
"model_kind": spec.get("model_kind", args.model_kind),
"memory_mode": spec.get("memory_mode"),
"speed_mode": spec.get("speed_mode"),
"attention_backend": spec.get("attention_backend"),
"transformer_cache": spec.get("transformer_cache"),
"transformer_quant": spec.get("transformer_quant"),
# On MiniMax-H3 the conditioner precision is a backend default, so "the released bfloat16
# encoder" is a spec value rather than the absence of one.
"text_encoder_quant": spec.get("text_encoder_quant"),
}
t0 = time.monotonic()
status = backend.load_pipeline(args.model, **load_kwargs)
load_s = time.monotonic() - t0
if torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
t0 = time.monotonic()
result = backend.generate(
prompt = args.prompt,
width = args.width,
height = args.height,
num_frames = args.frames,
fps = args.fps,
steps = args.steps,
guidance = args.guidance,
seed = args.seed,
)
generate_s = time.monotonic() - t0
peak_gib = torch.cuda.max_memory_allocated() / 2**30 if torch.cuda.is_available() else 0.0
backend.unload()
frames, audio = decode_mp4(result["mp4_bytes"], workdir, name)
return {
"frames": frames,
"audio": audio,
"load_s": round(load_s, 1),
"generate_s": round(generate_s, 1),
"peak_vram_gib": round(peak_gib, 2),
"resolved": {
k: v
for k, v in status.items()
if k
in (
"speed_mode",
"attention_backend",
"transformer_cache",
"transformer_quant",
"text_encoder_quant",
"speed_optims",
"offload_policy",
"model_kind",
)
},
}
def run_gate(args: Any) -> int:
from core.inference.video import get_video_backend
out_dir = Path(args.out_dir)
out_dir.mkdir(parents = True, exist_ok = True)
backend = get_video_backend()
print(f"reference: {args.reference or 'base'}", flush = True)
ref = run_config(backend, args, parse_spec(args.reference), out_dir, "reference")
print(
f" load {ref['load_s']}s, generate {ref['generate_s']}s, "
f"peak {ref['peak_vram_gib']} GiB",
flush = True,
)
rows = []
for spec_str in args.candidates:
spec = parse_spec(spec_str)
label = spec_label(spec)
print(f"candidate: {label}", flush = True)
cand = run_config(backend, args, spec, out_dir, label.replace("/", "_").replace("=", "-"))
metrics = clip_metrics(ref["frames"], cand["frames"], sample_count = args.sample_frames)
audio = audio_metrics(ref["audio"], cand["audio"])
row = {
"candidate": label,
**{
k: (round(v, 4) if isinstance(v, float) and math.isfinite(v) else v)
for k, v in metrics.items()
},
**{f"audio_{k}": v for k, v in audio.items()},
"load_s": cand["load_s"],
"generate_s": cand["generate_s"],
"ref_generate_s": ref["generate_s"],
"peak_vram_gib": cand["peak_vram_gib"],
"resolved": cand["resolved"],
"verdict": verdict(metrics, audio),
}
rows.append(row)
print(
f" ssim {row['ssim_mean']:.3f} | psnr {row['psnr_mean']:.1f} dB | "
f"temporal {row['temporal_deviation']:.3f} | luma>={row['min_luma']:.3f} | "
f"gen {row['generate_s']}s (ref {ref['generate_s']}s) | "
f"vram {row['peak_vram_gib']} GiB | {row['verdict']}",
flush = True,
)
report = {
"model": args.model,
"reference": args.reference or "base",
"prompt": args.prompt,
"shape": [args.width, args.height, args.frames, args.fps],
"steps": args.steps,
"guidance": args.guidance,
"seed": args.seed,
"reference_cost": {k: ref[k] for k in ("load_s", "generate_s", "peak_vram_gib")},
"candidates": rows,
}
(out_dir / "report.json").write_text(json.dumps(report, indent = 1))
print(f"report: {out_dir / 'report.json'}", flush = True)
return 0 if all(r["verdict"] != "FAIL" for r in rows) else 1
# ── selftest (CPU-only, synthetic clips, no torch/model) ────────────────────
def selftest() -> int:
import numpy as np
rng = np.random.default_rng(0)
h, w, n = 64, 96, 12
def make_clip(
offset = 0.0,
noise = 0.0,
black = False,
):
frames = []
for t in range(n):
x = np.linspace(0, 1, w)[None, :] + t * 0.05 + offset
base = (np.sin(x * 6.283) * 0.5 + 0.5) * 255.0
frame = np.repeat(base[..., None], 3, axis = 2) * np.ones((h, 1, 1))
if noise:
frame = frame + rng.normal(0, noise, frame.shape)
if black:
frame = frame * 0.0
frames.append(np.clip(frame, 0, 255).astype(np.uint8))
return frames
ref = make_clip()
ok = True
def check(cond, msg):
nonlocal ok
print(("PASS: " if cond else "FAIL: ") + msg)
ok = ok and cond
same = clip_metrics(ref, make_clip())
check(
same["ssim_mean"] > 0.99 and same["temporal_deviation"] < 0.01,
f"identical clip scores ~1 (ssim {same['ssim_mean']:.3f})",
)
check(verdict(same, {"silent_collapse": False}) == "PASS", "identical clip verdict PASS")
noisy = clip_metrics(ref, make_clip(noise = 12.0))
check(0.3 < noisy["ssim_mean"] < 0.99, f"noisy clip degrades ssim ({noisy['ssim_mean']:.3f})")
black = clip_metrics(ref, make_clip(black = True))
check(
verdict(black, {"silent_collapse": False}) == "FAIL",
f"black clip verdict FAIL (min_luma {black['min_luma']:.3f})",
)
shifted = clip_metrics(ref, make_clip(offset = 0.5))
check(shifted["ssim_mean"] < same["ssim_mean"], "content shift lowers ssim")
# A truncated render with a pixel-identical prefix must still FAIL on the frame-count mismatch.
truncated = clip_metrics(ref, make_clip()[: n // 2])
check(
truncated["frame_count_mismatch"] is True
and verdict(truncated, {"silent_collapse": False}) == "FAIL",
f"truncated clip verdict FAIL ({truncated['cand_frame_count']}/{truncated['ref_frame_count']} frames)",
)
audio = audio_metrics(np.sin(np.linspace(0, 100, 16000)), np.zeros(16000))
check(audio["silent_collapse"] is True, "silent audio collapse detected")
audio_ok = audio_metrics(
np.sin(np.linspace(0, 100, 16000)), np.sin(np.linspace(0, 100, 16000)) * 0.8
)
check(audio_ok["silent_collapse"] is False, "attenuated audio is not a collapse")
print("VIDEO-QUALITY-SELFTEST", "PASS" if ok else "FAIL")
return 0 if ok else 1
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__.split("\n")[0])
parser.add_argument("--selftest", action = "store_true", help = "CPU metric sanity check")
parser.add_argument("--model", help = "Repo id handed to the video backend")
parser.add_argument("--model-kind", default = None, help = "pipeline | gguf | single_file")
parser.add_argument(
"--reference", default = "", help = "Reference spec 'k=v;k=v' ('' = plain base load)"
)
parser.add_argument("--candidates", nargs = "+", default = [], help = "Candidate specs 'k=v;k=v'")
parser.add_argument("--prompt", default = DEFAULT_PROMPT)
parser.add_argument("--width", type = int, default = 768)
parser.add_argument("--height", type = int, default = 512)
parser.add_argument("--frames", type = int, default = 33)
parser.add_argument("--fps", type = int, default = 24)
parser.add_argument("--steps", type = int, default = None)
parser.add_argument("--guidance", type = float, default = None)
parser.add_argument("--seed", type = int, default = 7)
parser.add_argument("--sample-frames", type = int, default = 5)
parser.add_argument("--out-dir", default = "outputs/video_quality")
args = parser.parse_args()
if args.selftest:
return selftest()
if not args.model or not args.candidates:
parser.error("--model and --candidates are required (or use --selftest)")
return run_gate(args)
if __name__ == "__main__":
sys.exit(main())