diff --git a/client/src/pages/MediaModels.jsx b/client/src/pages/MediaModels.jsx
index 1e51bbdeeb..a780dca9c0 100644
--- a/client/src/pages/MediaModels.jsx
+++ b/client/src/pages/MediaModels.jsx
@@ -536,7 +536,7 @@ export default function MediaModels() {
)}
- GGUF-only, Wan, and HunyuanVideo repos are refused — no PortOS runtime can load them. For a GGUF LTX build, use the native MLX Q4 model instead.
+ GGUF-only, arbitrary Wan, and HunyuanVideo repos are refused because this flow cannot register a compatible runtime. For a GGUF LTX build, use the native MLX Q4 model instead.
diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx
index f2aec3c687..c750987032 100644
--- a/client/src/pages/VideoGen.jsx
+++ b/client/src/pages/VideoGen.jsx
@@ -788,7 +788,7 @@ export default function VideoGen() {
};
// `status.connected` reflects the LEGACY mlx_video pythonPath health. BYOV
- // runtimes (ltx2/wan22/hunyuan) resolve their own venv inside the service
+ // runtimes resolve their own venv inside the service
// layer, so a missing legacy pythonPath must NOT block them — gate only on
// `byovRuntimeMissing` for those models. Without this, a user who installed
// ONLY a BYOV runtime via the modal would stay stuck behind a "not
diff --git a/data.reference/media-models.json b/data.reference/media-models.json
index df0109302c..1aae9c1e8c 100644
--- a/data.reference/media-models.json
+++ b/data.reference/media-models.json
@@ -468,29 +468,6 @@
"reviewedAt": "2026-08-09"
}
},
- {
- "id": "hunyuan_video",
- "name": "HunyuanVideo (13B — fp32-only on MPS, ~4-8 hr per render)",
- "repo": "tencent/HunyuanVideo",
- "runtime": "hunyuan",
- "steps": 30,
- "guidance": 6,
- "precision": "fp32",
- "deprecated": true,
- "disclosure": {
- "modelCardUrl": "https://huggingface.co/tencent/HunyuanVideo",
- "weightsLicense": {
- "name": "Tencent Hunyuan Community License",
- "url": "https://huggingface.co/tencent/HunyuanVideo/blob/main/LICENSE"
- },
- "runtimeLicense": {
- "name": "Tencent Hunyuan Community License",
- "url": "https://github.com/gaurav-nelson/HunyuanVideo_MLX/blob/main/LICENSE.txt"
- },
- "estimatedDownloadGb": 39.8,
- "reviewedAt": "2026-08-09"
- }
- },
{
"id": "fastmetal_1_3b_qad",
"name": "FastMetal 1.3B QAD (~3.5 GB download, 8+ GB RAM, 3-step)",
diff --git a/scripts/_runner_common.py b/scripts/_runner_common.py
index 5f1178ecab..92e25c6ff3 100644
--- a/scripts/_runner_common.py
+++ b/scripts/_runner_common.py
@@ -33,8 +33,8 @@
# Lazy heavy-import note: `torch` and `PIL` are deferred into the functions
# that actually need them (pick_device, make_generator, make_stepwise_callback).
# Lightweight helpers — heartbeat, install_hf_error_handler, write_sidecar —
-# stay usable from venvs that haven't pip-installed torch yet (e.g. the
-# Hunyuan venv during a partial bootstrap).
+# stay usable from venvs that haven't pip-installed torch yet during a partial
+# runtime bootstrap.
def register_source_namespace(package_name: str, package_dir: "str | Path"):
diff --git a/scripts/generate_av_lora.py b/scripts/generate_av_lora.py
index 3feb2a20b6..b70965effe 100644
--- a/scripts/generate_av_lora.py
+++ b/scripts/generate_av_lora.py
@@ -36,7 +36,7 @@
# Sibling import: parse_user_loras is shared with generate_ltx2.py so the
# strict --user-loras validation contract lives in one place. sys.path[0] is
# already this dir when run as `python /abs/scripts/generate_av_lora.py`, but
-# insert defensively (mirrors generate_hunyuan.py). _runner_common is
+# insert defensively. _runner_common is
# stdlib-only at import time, so this is safe from the MLX venv.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _runner_common import emit_runtime_fingerprint, parse_user_loras # noqa: E402
diff --git a/scripts/generate_hunyuan.py b/scripts/generate_hunyuan.py
deleted file mode 100644
index fff757e0af..0000000000
--- a/scripts/generate_hunyuan.py
+++ /dev/null
@@ -1,389 +0,0 @@
-#!/usr/bin/env python3
-"""
-PortOS HunyuanVideo MLX helper.
-
-Calls hyvideo.inference.HunyuanVideoSampler from the cloned upstream repo
-directly — we no longer subprocess into upstream's sample_video_mps.py
-because that wrapper hardcodes infer_steps=40, guidance_scale=7.0, and forces
-fp32, which silently overrides whatever the user picked in the PortOS UI.
-Owning the predict() kwargs here gives the UI actual control.
-
-Spawned by server/services/videoGen/local.js when model.runtime === 'hunyuan'.
-
-The cloned repo at --repo-dir provides the `hyvideo` package; the model weights
-come from a huggingface_hub snapshot of the user-supplied --model-repo
-(typically `tencent/HunyuanVideo`).
-"""
-
-import argparse
-import gc
-import os
-import shutil
-import sys
-import unittest.mock
-from pathlib import Path
-
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-from _runner_common import emit_runtime_fingerprint, heartbeat, install_hf_error_handler # noqa: E402
-
-
-def parse_args() -> argparse.Namespace:
- p = argparse.ArgumentParser(description="PortOS HunyuanVideo MLX helper")
- p.add_argument("--repo-dir", required=True, help="Cloned HunyuanVideo_MLX repo root")
- p.add_argument("--model-repo", required=True, help="HF repo of HunyuanVideo weights (e.g. tencent/HunyuanVideo)")
- p.add_argument("--prompt", required=True)
- p.add_argument("--negative-prompt", default="")
- p.add_argument("--width", type=int, required=True)
- p.add_argument("--height", type=int, required=True)
- p.add_argument("--num-frames", type=int, default=129)
- p.add_argument("--steps", type=int, default=30)
- p.add_argument("--guidance", type=float, default=6.0, help="embedded_guidance_scale for the cfg-distilled model")
- p.add_argument("--seed", type=int, default=42)
- p.add_argument("--precision", default="fp32", choices=["fp16", "bf16", "fp32"],
- help="dtype for DiT + VAE + text encoder. fp32 is the ONLY working "
- "value on Apple Silicon MPS — verified empirically that both fp16 "
- "and bf16 trip `MPSNDArrayMatrixMultiplication.mm:5799 failed "
- "assertion: Destination NDArray and Accumulator NDArray cannot "
- "have different datatype` within ~2s of the first forward pass. "
- "MPS matmul kernels always use an fp32 accumulator internally, so "
- "a non-fp32 output dtype guarantees a mismatch. Upstream's "
- "sample_video_mps.py reaches the same conclusion. The choices list "
- "keeps fp16/bf16 selectable in case a future PyTorch/MPS release "
- "fixes this, but DO NOT switch the default without re-testing.")
- p.add_argument("--output", required=True)
- return p.parse_args()
-
-
-def install_tqdm_step_emitter(stage: str) -> None:
- """Monkey-patch tqdm so each `.update()` also prints a STAGE: line that
- local.js's handleLine() parses into an SSE progress event.
-
- HunyuanVideo's diffusion loop drives a tqdm progress bar via
- `pipeline.progress_bar(total=num_inference_steps)`. Out of the box,
- tqdm against a pipe (no TTY) emits very sparse output — and even those
- writes are buffered enough that the UI sees nothing for the entire
- ~30-90 min run. We patch the base class so every step also pushes
- `STAGE:inference:step:::diffusion step N/M` to stderr;
- PYTHONUNBUFFERED=1 (set on the spawn env) guarantees it flushes.
-
- `tqdm.tqdm` and `tqdm.auto.tqdm` are separate references — diffusers'
- `progress_bar` uses `tqdm.auto`, but other libs reach for `tqdm.tqdm`
- directly. Patch both for safety.
- """
- import tqdm
- import tqdm.auto
- orig_cls = tqdm.tqdm
-
- class StageEmittingTqdm(orig_cls):
- def update(self, n=1):
- ret = super().update(n)
- try:
- cur = int(self.n)
- total = int(self.total) if self.total else 0
- except Exception:
- return ret
- print(
- f"STAGE:{stage}:step:{cur}:{total}:diffusion step {cur}/{total}",
- file=sys.stderr,
- flush=True,
- )
- return ret
-
- tqdm.tqdm = StageEmittingTqdm
- tqdm.auto.tqdm = StageEmittingTqdm
-
-
-def ensure_transformers_compat_shims():
- """Make hyvideo's transformers-4.x assumptions still work on transformers 5.x.
- Idempotent.
-
- hyvideo was written when `CLIPTextModel` exposed its inner stack through a
- `.text_model` attribute. transformers ≥5 flattened that. The mismatch
- trips `hyvideo/text_encoder/__init__.py:34`:
- `text_encoder.final_layer_norm = text_encoder.text_model.final_layer_norm`
- Adding a self-reference makes that reassignment a no-op (the right-hand
- side already exists at the top level in 5.x).
-
- Companion bandaid: stage_hunyuan_model_base() walks both
- `model.model.language_model` and `model.language_model` for the same
- transformers-5.x flattening on the Llava side.
- """
- import transformers
- if not hasattr(transformers.CLIPTextModel, "text_model"):
- transformers.CLIPTextModel.text_model = property(lambda self: self)
-
-
-# Repos Hunyuan needs alongside the main tencent/HunyuanVideo snapshot.
-# tencent/HunyuanVideo ships only the DiT + VAE; the two text encoders
-# (Llama-3 backbone from Llava, plus CLIP) live in separate public repos
-# and have to be staged into the MODEL_BASE tree at the paths hyvideo's
-# constants.py expects (/text_encoder, /text_encoder_2).
-LLAVA_REPO = "xtuner/llava-llama-3-8b-v1_1-transformers"
-CLIP_REPO = "openai/clip-vit-large-patch14"
-# CLIP files used by hyvideo's text encoder loader — explicit allowlist
-# keeps the snapshot tight (CLIP ships training artifacts we don't need).
-CLIP_FILES = (
- "config.json",
- "model.safetensors",
- "preprocessor_config.json",
- "tokenizer.json",
- "tokenizer_config.json",
- "vocab.json",
- "merges.txt",
- "special_tokens_map.json",
-)
-
-
-def stage_hunyuan_model_base(repo_dir, snapshot_dir):
- """Build a writable MODEL_BASE tree with everything hyvideo needs.
-
- Layout under `/portos-models/`:
- - hunyuan-video-t2v-720p/ — symlink to /hunyuan-video-t2v-720p
- (gives the DiT + VAE without duplicating GB)
- - text_encoder/ — Llama backbone extracted from Llava-Llama-3
- - text_encoder_2/ — CLIP ViT-L/14
-
- Idempotent: skips downloads + preprocessing when the destination already
- looks populated. First-run extra cost is ~17 GB of downloads + a CPU
- preprocess pass that takes a couple of minutes.
- """
- base = Path(repo_dir) / "portos-models"
- base.mkdir(parents=True, exist_ok=True)
-
- src_main = Path(snapshot_dir) / "hunyuan-video-t2v-720p"
- dst_main = base / "hunyuan-video-t2v-720p"
- # Path.exists() follows symlinks, so a dangling link (snapshot pruned
- # from HF cache between runs) would return False AND then symlink_to
- # would raise FileExistsError because the link itself is present. Use
- # is_symlink to detect that case and recreate. lexists doesn't help
- # because we'd still need to unlink before symlink_to.
- if dst_main.is_symlink() and not dst_main.exists():
- dst_main.unlink()
- if not dst_main.exists():
- dst_main.symlink_to(src_main, target_is_directory=True)
-
- from huggingface_hub import snapshot_download as _snap
-
- te2 = base / "text_encoder_2"
- if not (te2 / "config.json").is_file():
- # Stage into `.partial` and atomic-rename so an interrupted snapshot
- # (Ctrl-C, network drop) can't satisfy the `config.json`-only
- # idempotency gate above with a half-downloaded directory — without
- # this, the next run would skip the download and fail deep inside
- # hyvideo with a confusing missing-weights error. Mirrors the
- # text_encoder branch below.
- te2_staging = te2.with_name(te2.name + ".partial")
- shutil.rmtree(te2_staging, ignore_errors=True)
- te2_staging.mkdir(parents=True)
- print(f"STAGE:download-clip:{CLIP_REPO}", file=sys.stderr, flush=True)
- with heartbeat("download-clip"):
- _snap(CLIP_REPO, local_dir=str(te2_staging), allow_patterns=list(CLIP_FILES))
- shutil.rmtree(te2, ignore_errors=True)
- os.replace(te2_staging, te2)
-
- te = base / "text_encoder"
- if not (te / "config.json").is_file():
- # Llava ships ~16 GB of weights; we only need the language_model
- # half (the Llama backbone) plus the tokenizer. Download into a
- # scratch dir, run upstream's split, then delete the raw download.
- scratch = base / "_llava_raw"
- print(f"STAGE:download-llava:{LLAVA_REPO}", file=sys.stderr, flush=True)
- with heartbeat("download-llava"):
- _snap(LLAVA_REPO, local_dir=str(scratch))
-
- print("STAGE:preprocess-text-encoder", file=sys.stderr, flush=True)
- # transformers + torch are heavy imports — defer until we know we
- # need them (subsequent renders skip this whole branch).
- from transformers import AutoProcessor, LlavaForConditionalGeneration
- import torch
- with heartbeat("preprocess-llava"):
- processor = AutoProcessor.from_pretrained(str(scratch))
- # fp16 halves the peak RAM during preprocessing (Llava is ~16 GB
- # at fp32, ~8 GB at fp16). hyvideo loads text_encoder at fp16
- # downstream anyway, so we save nothing by extracting at fp32.
- model = LlavaForConditionalGeneration.from_pretrained(
- str(scratch),
- torch_dtype=torch.float16,
- low_cpu_mem_usage=True,
- device_map=None,
- )
- # transformers ≥5 wraps the Llama backbone in an outer LlavaModel
- # (model.model.language_model); 4.x had it flat (model.language_model).
- # Walk one level deep first, fall back to flat, raise on neither.
- language_model = getattr(getattr(model, "model", model), "language_model", None)
- if language_model is None:
- raise AttributeError(
- f"Could not locate Llava's language_model on {type(model).__name__}; "
- "transformers structure changed again."
- )
- # Atomic write via `.partial` rename — a crash mid save_pretrained
- # would otherwise leave a half-written text_encoder/ that the
- # config.json idempotency check above would treat as complete.
- staging = te.with_name(te.name + ".partial")
- shutil.rmtree(staging, ignore_errors=True)
- staging.mkdir(parents=True)
- language_model.save_pretrained(str(staging), safe_serialization=True)
- processor.tokenizer.save_pretrained(str(staging))
- del model, processor, language_model
- gc.collect()
- os.replace(staging, te)
-
- def _warn_on_rmtree_error(func, path, exc_info):
- print(f"⚠️ hunyuan: failed to clean scratch {path}: {exc_info[1]}", file=sys.stderr)
- shutil.rmtree(scratch, onerror=_warn_on_rmtree_error)
-
- return base
-
-
-@install_hf_error_handler
-def main() -> int:
- cli = parse_args()
-
- # Runtime fingerprint at startup — recorded by PortOS so garbled output can
- # be tied to a specific torch/diffusers/mlx stack on this chip.
- emit_runtime_fingerprint("hunyuan", ["torch", "diffusers", "transformers", "mlx"])
-
- repo_dir = Path(cli.repo_dir).expanduser().resolve()
- if not repo_dir.is_dir():
- print(f"❌ HunyuanVideo MLX repo not found at {repo_dir}", file=sys.stderr)
- return 64
-
- # hyvideo isn't pip-installed — it's a package directory inside the repo.
- # Prepend the repo so `import hyvideo.*` resolves without a chdir.
- sys.path.insert(0, str(repo_dir))
-
- # MPS probe BEFORE the multi-GB snapshot — no point pulling 30 GB if the
- # render can't run. torch import is ~5s, snapshot is gigabytes.
- import torch
- if not torch.backends.mps.is_available():
- print("❌ MPS not available — HunyuanVideo MLX requires Apple Silicon", file=sys.stderr)
- return 64
-
- print(f"STAGE:download-weights:{cli.model_repo}", file=sys.stderr, flush=True)
- try:
- from huggingface_hub import snapshot_download
- except Exception as err:
- print(f"❌ huggingface_hub import failed: {err}", file=sys.stderr)
- return 64
- # snapshot_download can stall for minutes on a single multi-GB shard;
- # heartbeat keeps the PortOS idle watchdog from killing us mid-pull.
- with heartbeat("download-weights"):
- ckpt_dir = snapshot_download(cli.model_repo)
- print(f"🔧 hunyuan: weights ← {ckpt_dir}", file=sys.stderr)
-
- # tencent/HunyuanVideo ships only the DiT + VAE — the text encoders are
- # separate public repos that have to be staged into a MODEL_BASE tree
- # at the paths hyvideo.constants expects. First run downloads ~17 GB
- # extra; subsequent renders are no-op.
- model_base = stage_hunyuan_model_base(repo_dir, ckpt_dir)
- print(f"🔧 hunyuan: model base ← {model_base}", file=sys.stderr)
-
- # hyvideo.constants reads MODEL_BASE at import time to build VAE_PATH and
- # TEXT_ENCODER_PATH. Set before importing hyvideo or the lookup hits the
- # default "./ckpts" (relative to cwd) and fails to find the encoders.
- os.environ["MODEL_BASE"] = str(model_base)
-
- print("STAGE:load-pipeline", file=sys.stderr, flush=True)
- # Install BEFORE importing hyvideo / diffusers — they capture references
- # to `tqdm.tqdm` at import time, so a later patch won't reach the bar
- # that pipeline_hunyuan_video.py actually uses for the diffusion loop.
- install_tqdm_step_emitter("inference")
- ensure_transformers_compat_shims()
- from hyvideo.config import parse_args as hv_parse_args
- from hyvideo.inference import HunyuanVideoSampler
- from hyvideo.utils.file_utils import save_videos_grid
-
- # Borrow hyvideo's full argparse namespace so every model default
- # (latent_channels, vae='884-16c-hy', flow_shift, etc.) is populated.
- # mock.patch.object scopes the sys.argv swap to the parse_args call so a
- # signal handler firing mid-block can't see our placeholder argv.
- with unittest.mock.patch.object(sys, "argv", ["generate_hunyuan"]):
- hv = hv_parse_args()
-
- # Override only the fields the UI controls. precision is forced equal
- # across DiT / VAE / text encoder — three precision knobs on a video gen
- # form would be excessive, and they typically need to match for MPS.
- hv.model_base = str(model_base)
- hv.dit_weight = str(model_base / "hunyuan-video-t2v-720p" / "transformers" / "mp_rank_00_model_states.pt")
- hv.video_size = [int(cli.height), int(cli.width)]
- hv.video_length = int(cli.num_frames)
- hv.infer_steps = int(cli.steps)
- hv.embedded_cfg_scale = float(cli.guidance)
- hv.seed = int(cli.seed)
- hv.prompt = cli.prompt
- hv.neg_prompt = cli.negative_prompt or None
- hv.precision = cli.precision
- hv.vae_precision = cli.precision
- hv.text_encoder_precision = cli.precision
- # hyvideo carries a separate precision knob for the second text encoder
- # (CLIP-L). Without this, CLIP-L silently loads at its default (fp16) even
- # when we ask for fp32 everywhere else — and on MPS that single mismatched
- # dtype is enough to trip the matmul accumulator assertion at the first
- # text-encoder forward pass.
- hv.text_encoder_precision_2 = cli.precision
- # Autocast on MPS produces NaNs for some Hunyuan layers; the upstream
- # wrapper disabled it too. Keep off until tested.
- hv.disable_autocast = True
-
- # Fail-fast: from_pretrained spends ~1-3 min loading other shards before
- # discovering the DiT file is missing. Check up front for a clear error.
- if not Path(hv.dit_weight).is_file():
- print(f"❌ expected DiT weights missing: {hv.dit_weight}", file=sys.stderr)
- return 64
-
- device = torch.device("mps")
- print(
- f"🎬 hunyuan generate {cli.width}x{cli.height} frames={cli.num_frames} "
- f"steps={cli.steps} guidance={cli.guidance} precision={cli.precision}",
- file=sys.stderr,
- )
-
- print("STAGE:from-pretrained", file=sys.stderr, flush=True)
- # from_pretrained loads ~30 GB into MPS on a cold run — silent for
- # minutes (mmap + weight assignment, no tqdm). Same idle-watchdog
- # concern as the snapshot pull.
- with heartbeat("from-pretrained"):
- sampler = HunyuanVideoSampler.from_pretrained(model_base, args=hv, device=device)
-
- print("STAGE:inference", file=sys.stderr, flush=True)
- outputs = sampler.predict(
- prompt=hv.prompt,
- height=hv.video_size[0],
- width=hv.video_size[1],
- video_length=hv.video_length,
- seed=hv.seed,
- negative_prompt=hv.neg_prompt,
- infer_steps=hv.infer_steps,
- # cfg_scale stays at the hyvideo default (1.0) — the cfg-distilled
- # variant uses embedded_guidance_scale as the real CFG knob.
- guidance_scale=hv.cfg_scale,
- embedded_guidance_scale=hv.embedded_cfg_scale,
- flow_shift=hv.flow_shift,
- batch_size=hv.batch_size,
- num_videos_per_prompt=hv.num_videos,
- )
-
- # `outputs["samples"]` may be a list, None, or — in some hyvideo paths — a
- # torch tensor. Don't use `or []`: bool(tensor) on a multi-element tensor
- # raises `RuntimeError: Boolean value of Tensor with more than one value
- # is ambiguous` before we ever reach save_videos_grid. Check explicitly.
- samples = outputs.get("samples")
- if samples is None or len(samples) == 0:
- print("❌ hunyuan predict() returned no samples", file=sys.stderr)
- return 1
-
- Path(cli.output).parent.mkdir(parents=True, exist_ok=True)
- # samples[0] is (C, T, H, W) — save_videos_grid wants (B, C, T, H, W),
- # so unsqueeze the batch dim before writing.
- save_videos_grid(samples[0].unsqueeze(0), cli.output, fps=24)
-
- if not Path(cli.output).exists():
- print(f"❌ hunyuan finished but {cli.output} missing", file=sys.stderr)
- return 1
-
- print(f"✅ hunyuan saved {cli.output}", file=sys.stderr)
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/generate_ltx2.py b/scripts/generate_ltx2.py
index 46f507be2d..f30e6498cd 100644
--- a/scripts/generate_ltx2.py
+++ b/scripts/generate_ltx2.py
@@ -83,7 +83,7 @@ class (_resolve_pipeline) and the rate keyword (_rate_kwargs) from the live
# Sibling import: parse_user_loras is shared with generate_av_lora.py (the
# mlx_video LoRA runtime) so the strict --user-loras validation lives in one
# place. sys.path[0] is already this dir when run as a script; insert defensively
-# (mirrors generate_hunyuan.py). _runner_common is stdlib-only at import time, so
+# for direct and imported execution. _runner_common is stdlib-only at import time, so
# this is safe from the ltx-2-mlx venv (no torch pulled in).
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _runner_common import emit_runtime_fingerprint, parse_user_loras, write_stepwise_preview # noqa: E402
diff --git a/scripts/migrations/315-retire-hunyuan-video.js b/scripts/migrations/315-retire-hunyuan-video.js
new file mode 100644
index 0000000000..8d63fdb9ad
--- /dev/null
+++ b/scripts/migrations/315-retire-hunyuan-video.js
@@ -0,0 +1,55 @@
+/**
+ * Retire the legacy HunyuanVideo MLX profile from existing registries.
+ *
+ * The shipped 13B checkpoint is fp32-only on MPS and takes roughly 4-8 hours
+ * per render. HunyuanVideo 1.5 is a different CUDA runtime, not an upgrade the
+ * pinned community MLX checkout can load. FastMetal is the supported native
+ * Apple-Silicon replacement and its smallest profile covers the same text-only
+ * workflow with a much lower memory and step count.
+ *
+ * Fresh installs receive the shorter catalog from data.reference. Existing
+ * installs need this on-disk cleanup because their persisted registry is the
+ * picker source of truth. mediaModels.js carries the matching load-time guard
+ * so a registry cached before migrations run cannot write the retired row back.
+ * A user-repointed entry is preserved as user configuration rather than being
+ * mistaken for the shipped profile.
+ */
+
+import { VIDEO_BUCKET_MLX, resolveVideoDefaultKey } from '../../server/lib/mediaModelBuckets.js';
+import { readMediaRegistry, writeMediaRegistry } from './_lib.js';
+
+export const RETIRED_ID = 'hunyuan_video';
+export const SHIPPED_REPO = 'tencent/HunyuanVideo';
+export const REPLACEMENT_ID = 'fastmetal_1_3b_qad';
+
+export default {
+ async up({ rootDir }) {
+ const { ok, config, entries: mlxEntries, bucketKey, path } = await readMediaRegistry({ rootDir });
+ if (!ok) return;
+
+ const entry = mlxEntries.find((model) => model?.id === RETIRED_ID);
+ if (!entry) {
+ console.log(`✅ media-models: no '${RETIRED_ID}' entry — already retired, nothing to migrate`);
+ return;
+ }
+ if (entry.repo !== SHIPPED_REPO) {
+ console.log(`✅ media-models: '${RETIRED_ID}' points at ${entry.repo} — user-repointed, leaving it alone`);
+ return;
+ }
+
+ const kept = mlxEntries.filter((model) => model?.id !== RETIRED_ID);
+ config.video[bucketKey] = kept;
+
+ let defaultNote = '';
+ const defaultKey = resolveVideoDefaultKey(config.video, VIDEO_BUCKET_MLX);
+ if (defaultKey !== null
+ && config.video[defaultKey] === RETIRED_ID
+ && kept.some((model) => model?.id === REPLACEMENT_ID)) {
+ config.video[defaultKey] = REPLACEMENT_ID;
+ defaultNote = `; default video model → ${REPLACEMENT_ID}`;
+ }
+
+ await writeMediaRegistry(path, config);
+ console.log(`📝 media-models: retired '${RETIRED_ID}' — use FastMetal instead${defaultNote}`);
+ },
+};
diff --git a/scripts/migrations/315-retire-hunyuan-video.test.js b/scripts/migrations/315-retire-hunyuan-video.test.js
new file mode 100644
index 0000000000..7878ace44b
--- /dev/null
+++ b/scripts/migrations/315-retire-hunyuan-video.test.js
@@ -0,0 +1,106 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { repoRoot } from './_testHelpers.js';
+import { RETIRED_VIDEO_MODELS } from '../../server/lib/mediaModels.js';
+import migration, { REPLACEMENT_ID, RETIRED_ID, SHIPPED_REPO } from './315-retire-hunyuan-video.js';
+
+const REFERENCE_PATH = join(repoRoot, 'data.reference', 'media-models.json');
+const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
+const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8'));
+
+const shippedHunyuan = () => ({
+ id: RETIRED_ID,
+ name: 'HunyuanVideo legacy profile',
+ repo: SHIPPED_REPO,
+ runtime: 'hunyuan',
+ steps: 30,
+ guidance: 6,
+ deprecated: true,
+});
+
+const registryWith = (hunyuan, overrides = {}) => ({
+ video: {
+ macos: [
+ ...(hunyuan ? [hunyuan] : []),
+ { id: REPLACEMENT_ID, name: 'FastMetal 1.3B', runtime: 'fastvideo' },
+ ],
+ windows: [{ id: 'ltx_video', name: 'LTX-Video' }],
+ defaultMacos: RETIRED_ID,
+ defaultWindows: 'ltx_video',
+ ...overrides,
+ },
+ image: [],
+ _shippedDefaults: { video: { macos: [RETIRED_ID, REPLACEMENT_ID], windows: ['ltx_video'] } },
+});
+
+describe('migration 315 — retire legacy HunyuanVideo', () => {
+ let rootDir;
+ let path;
+
+ beforeEach(() => {
+ rootDir = mkdtempSync(join(tmpdir(), 'migration-315-'));
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ path = join(rootDir, 'data', 'media-models.json');
+ });
+
+ afterEach(() => rmSync(rootDir, { recursive: true, force: true }));
+
+ it('shares its retirement contract with the registry loader', () => {
+ expect(RETIRED_VIDEO_MODELS[RETIRED_ID]).toEqual({
+ shippedRepo: SHIPPED_REPO,
+ replacement: REPLACEMENT_ID,
+ });
+ });
+
+ it('matches the fresh-install catalog', () => {
+ const seeded = JSON.parse(readFileSync(REFERENCE_PATH, 'utf-8'));
+ expect(seeded.video.mlx.some((entry) => entry.id === RETIRED_ID)).toBe(false);
+ expect(seeded.video.mlx.some((entry) => entry.id === REPLACEMENT_ID)).toBe(true);
+ });
+
+ it('removes the shipped profile and repoints its configured default', async () => {
+ writeJson(path, registryWith(shippedHunyuan()));
+ await migration.up({ rootDir });
+
+ const after = readJson(path);
+ expect(after.video.macos.map((entry) => entry.id)).toEqual([REPLACEMENT_ID]);
+ expect(after.video.defaultMacos).toBe(REPLACEMENT_ID);
+ expect(after._shippedDefaults.video.macos).toContain(RETIRED_ID);
+ });
+
+ it('preserves a user-repointed entry and its default', async () => {
+ const customized = shippedHunyuan();
+ customized.repo = 'example-org/custom-video-runtime';
+ const before = registryWith(customized);
+ writeJson(path, before);
+
+ await migration.up({ rootDir });
+
+ expect(readJson(path)).toEqual(before);
+ });
+
+ it('leaves the stale default when the replacement is absent', async () => {
+ const before = registryWith(shippedHunyuan());
+ before.video.macos = [shippedHunyuan()];
+ writeJson(path, before);
+
+ await migration.up({ rootDir });
+
+ const after = readJson(path);
+ expect(after.video.macos).toEqual([]);
+ expect(after.video.defaultMacos).toBe(RETIRED_ID);
+ });
+
+ it('is idempotent and skips a missing registry', async () => {
+ writeJson(path, registryWith(shippedHunyuan()));
+ await migration.up({ rootDir });
+ const once = readJson(path);
+ await migration.up({ rootDir });
+ expect(readJson(path)).toEqual(once);
+
+ rmSync(path);
+ await expect(migration.up({ rootDir })).resolves.toBeUndefined();
+ });
+});
diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh
index 083ca8c9e7..3aed7e235b 100755
--- a/scripts/setup-image-video.sh
+++ b/scripts/setup-image-video.sh
@@ -100,7 +100,7 @@ mkdir -p "${PORTOS_DATA}/videos"
mkdir -p "${PORTOS_DATA}/video-thumbnails"
# When the user only wants a specific BYOV runtime (set via INSTALL_LTX2 /
-# INSTALL_WAN22 / INSTALL_HUNYUAN / INSTALL_MINIMAX_H3 / INSTALL_MINIMAX_H3_CUDA — or one of the self-contained MUSIC venvs
+# INSTALL_WAN22 / INSTALL_MINIMAX_H3 / INSTALL_MINIMAX_H3_CUDA — or one of the self-contained MUSIC venvs
# INSTALL_MUSICGEN / INSTALL_AUDIOLDM2 / INSTALL_ACESTEP / INSTALL_ACESTEP15 / INSTALL_MINIMAX_MUSIC3_MLX — typically from the
# in-app installer), skip the mflux + legacy mlx_video preamble. Those
# bring-your-own-venv runtimes are self-contained and don't depend on mflux;
@@ -109,7 +109,7 @@ mkdir -p "${PORTOS_DATA}/video-thumbnails"
# install ever starts — which on Linux/CPU/CUDA blocks the advertised
# `INSTALL_ACESTEP=1 bash …` path. A bare `bash setup-image-video.sh` still
# installs mflux as before.
-ANY_BYOV="${INSTALL_LTX2:-0}${INSTALL_LTX25:-0}${INSTALL_FASTVIDEO:-0}${INSTALL_WAN22:-0}${INSTALL_HUNYUAN:-0}${INSTALL_MINIMAX_H3:-0}${INSTALL_MINIMAX_H3_CUDA:-0}${INSTALL_MUSICGEN:-0}${INSTALL_AUDIOLDM2:-0}${INSTALL_ACESTEP:-0}${INSTALL_ACESTEP15:-0}${INSTALL_MINIMAX_MUSIC3:-0}${INSTALL_MINIMAX_MUSIC3_MLX:-0}${INSTALL_MUSCRIPTOR:-0}"
+ANY_BYOV="${INSTALL_LTX2:-0}${INSTALL_LTX25:-0}${INSTALL_FASTVIDEO:-0}${INSTALL_WAN22:-0}${INSTALL_MINIMAX_H3:-0}${INSTALL_MINIMAX_H3_CUDA:-0}${INSTALL_MUSICGEN:-0}${INSTALL_AUDIOLDM2:-0}${INSTALL_ACESTEP:-0}${INSTALL_ACESTEP15:-0}${INSTALL_MINIMAX_MUSIC3:-0}${INSTALL_MINIMAX_MUSIC3_MLX:-0}${INSTALL_MUSCRIPTOR:-0}"
# "no BYOV runtime was requested" = the concatenation contains no non-zero
# character. Matching a literal string of zeros instead made this a counting
# exercise that the string and the variable list had to agree on — and they had
@@ -575,64 +575,6 @@ if [[ "$INSTALL_MINIMAX_H3_CUDA" == "1" ]]; then
echo " That download is ~144 GB, and rendering needs ~24 GB VRAM plus ~75 GB of system RAM for offloaded weights."
fi
-INSTALL_HUNYUAN="${INSTALL_HUNYUAN:-0}"
-if [[ "$INSTALL_HUNYUAN" == "1" ]]; then
- # gaurav-nelson/HunyuanVideo_MLX — community MLX port of Tencent's
- # HunyuanVideo (13B). ~60 GB resident at bf16. Practical only with the
- # 4-bit Gemma text encoder + everything else evicted (see the Memory
- # Management panel under Settings → Local LLMs).
- #
- # EXPERIMENTAL — same caveat as Wan 2.2: the clone is pinned (HUNYUAN_PIN
- # below) for reproducible installs, but bumping that pin can still drift
- # sample_video.py args. If it does, flip `hunyuan_video` broken in
- # data/media-models.json and update scripts/generate_hunyuan.py.
- if ! have uv; then
- echo "❌ INSTALL_HUNYUAN=1 requires the 'uv' Python installer." >&2
- exit 1
- fi
- if ! have git; then
- echo "❌ INSTALL_HUNYUAN=1 requires git." >&2
- exit 1
- fi
- # Pinned to a known-good commit (the repo's HEAD as of 2026-06-02). Floating
- # `main` on a community-maintained port means every new install gets whatever
- # HEAD is that day — a pin keeps installs reproducible. To upgrade: bump this
- # SHA and verify with PortOS's video gen smoke tests. Set HUNYUAN_PIN=main to
- # bypass the pin and track upstream HEAD for development.
- HUNYUAN_PIN="${HUNYUAN_PIN:-d5ec346aac3322066c1f1cb149830d1246dbe6dd}"
- HUNYUAN_DIR="${HOME}/.portos/hunyuan-video-mlx"
- HUNYUAN_PY="${HUNYUAN_DIR}/.venv/bin/python3"
- mkdir -p "${HOME}/.portos"
- if [[ ! -d "${HUNYUAN_DIR}/.git" ]]; then
- echo "📦 Cloning gaurav-nelson/HunyuanVideo_MLX..."
- git clone --progress https://github.com/gaurav-nelson/HunyuanVideo_MLX.git "${HUNYUAN_DIR}"
- else
- echo "📦 Fetching HunyuanVideo_MLX updates..."
- (cd "${HUNYUAN_DIR}" && git fetch --progress origin)
- fi
- git_checkout_pin "${HUNYUAN_DIR}" "${HUNYUAN_PIN}"
- if [[ ! -x "${HUNYUAN_PY}" ]]; then
- echo "📦 Creating HunyuanVideo_MLX venv with Python 3.11..."
- (cd "${HUNYUAN_DIR}" && uv venv --python 3.11)
- fi
- # Upstream gaurav-nelson ships requirements as `requirements_mps.txt` (the
- # MPS-specific variant — there's no plain `requirements.txt`). Prefer the
- # MPS one when present, then plain `requirements.txt`, then fall back to
- # `uv sync` for repos that use pyproject + lockfile instead.
- HUNYUAN_REQS=""
- for cand in requirements_mps.txt requirements.txt; do
- if [[ -f "${HUNYUAN_DIR}/${cand}" ]]; then HUNYUAN_REQS="$cand"; break; fi
- done
- if [[ -n "$HUNYUAN_REQS" ]]; then
- echo "📦 Installing HunyuanVideo_MLX requirements from ${HUNYUAN_REQS}..."
- (cd "${HUNYUAN_DIR}" && uv pip install -r "$HUNYUAN_REQS")
- else
- echo "📦 Syncing HunyuanVideo_MLX packages..."
- (cd "${HUNYUAN_DIR}" && uv sync)
- fi
- echo "✅ HunyuanVideo_MLX venv ready: ${HUNYUAN_PY}"
-fi
-
INSTALL_MUSICGEN="${INSTALL_MUSICGEN:-0}"
if [[ "$INSTALL_MUSICGEN" == "1" ]]; then
# Local background-music generation for the pipeline audio stage (Phase
diff --git a/server/lib/README.md b/server/lib/README.md
index 552d3c1930..abc3b2096f 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -149,7 +149,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `videoDisclosure.js` | Video Gen provenance/licensing + backend policy-scope facts (#3674). `VIDEO_MODEL_DISCLOSURES` maps each shipped video model id to `{ shippedRepo, disclosure }` (model card URL, weights license, runtime license, pinned-snapshot download size in decimal GB, review date) — every value checked against a primary upstream source, and any fact that could not be established is OMITTED so the UI renders "Unknown" instead of guessing. `applyVideoDisclosures(list)` is the load-time backfill (twin of migration 237) with the same preservation guards: an existing `disclosure` key wins, a custom id is skipped, and a `repo` pointed at a fork keeps Unknown. `VIDEO_BACKEND_DISCLOSURES` / `videoBackendDisclosure(id)` state where inference runs (`execution: local or hosted`) and whose policy applies — execution facts only, never a restrictiveness ranking. `APACHE_2` / `GEMMA_TERMS` are the shared license descriptors `videoTextEncoders.js` reuses, so a license-text correction reaches both tables from one edit. |
| `videoFinishProfiles.js` | Draft → delivery ("Finish") relationships between video models (#3696). `VIDEO_FINISH_PROFILES` declares each fast draft entry id → `{ shippedRepo, finishModelId }`, only for pairs that share a runtime, base repo and supported modes (the same weights at a different step budget), so re-rendering the draft's seed reproduces its composition instead of re-rolling it. `applyVideoFinishProfiles(list)` is the load-time backfill (twin of migration 238) with the usual preservation guards (existing key wins, custom id skipped, forked `repo` skipped); `validateFinishProfileGraph(list)` returns the graph problems (missing / self-referencing / chained target, runtime / repo / supportedModes mismatch) and `sanitizeFinishProfiles(list)` warns + strips them at load so a typo can never surface a Finish button targeting nothing. `finishTargetForModel(model, availableModels)` resolves the delivery entry scoped to what this install can run. |
| `videoSpeedProfiles.js` | Named, pre-validated sampler schedules a user can pick instead of hand-tuning steps/CFG (#4875). `VIDEO_SPEED_PROFILES` declares each entry id → `{ shippedRepo, shippedRevision, profiles }`; `applyVideoSpeedProfiles(list)` is the load-time backfill (twin of migration 295) and guards BOTH `repo` and `revision` — unlike the finish-profile decorator, because a sampler schedule is revision-sensitive in a way a draft→delivery edge is not. `SPEED_PROFILE_DEFAULT_ID` (`'quality'`) is a deliberate NO-OP so a default render builds byte-identical spawn args and stamps no extra history fields; `isDefaultSpeedProfile(id)` makes absence and that sentinel the same request. `speedProfileDeclineReason({ model, profileId, mode })` RETURNS (never throws) the reason a profile does not apply — wrong mode, unpinned weights, `samplerLocked` model, unknown id — so a knob that only makes a render faster degrades instead of 400ing a submitted job, and `resolveVideoSpeedProfile()` returns the concrete override or `null`. `resolveVideoSpeedProfileForModes({ model, profileId, modes })` lifts that decline check to a CHAINED render — a chain is one clip whose chunks run in different modes (chunk 0 the request's, chunks 1+ `extend` on a window-continuity chain or `image` on a frame hop), so the profile applies to every chunk or to none rather than seaming a fast chunk onto quality ones. `resolveVideoSampler({ model, steps, guidanceScale, speedProfile })` is the SINGLE precedence rule (samplerLocked > profile > explicit request > registry default), shared by the render path and the chained-render ETA so the two cannot drift. `validateSpeedProfileTable(list)` / `sanitizeSpeedProfiles(list)` warn + strip a hand-edited profile (NaN steps, duplicate/reserved id, samplerLocked collision) at load rather than letting it spawn a broken render. What the RUNNER could actually apply — is the pinned pipeline new enough for `enable_teacache`, is the distilled adapter in the pack — is probed by `scripts/generate_ltx2.py` and reported back on a `SPEEDPROFILE:` line. |
-| `videoModeProfiles.js` | The shipped `supportedModes` fact per video runtime (#3737), so "which modes does this model support?" has one answer on the registry entry instead of 25+ `runtime === '…'` comparisons. `VIDEO_RUNTIME_MODES` maps each runtime to the base semantic modes its helper's argv can actually render (`mlx_video`/`ltx2` text+image+fflf+extend, `wan22` text+image, `minimax_h3` / `minimax_h3_cuda` text+image+fflf off one shared array — also the mode ceiling in `videoGen/modeContract.js` — `hunyuan` text only, replacing the legacy unread `mode: 't2v'` field); `VIDEO_BASE_MODES` is the fallback for an unknown (user-added or peer-synced) runtime, deliberately the full base set so a custom model doesn't vanish from every picker. `resolveVideoSupportedModes(entry)` (declared non-empty list always wins) and `applyVideoSupportedModes(list)` — the read-time decoration `mediaModels.js#getVideoModels` applies, so the derived list never lands in `data/media-models.json` where it would read back as a *declared* list no later correction could reach. a2v and the IC-LoRA remix ids are NOT enumerated here: they're ltx2 pipeline capabilities, gated by runtime (`A2V_REQUIRES_LTX2` / `IC_LORA_REQUIRES_LTX2`). |
+| `videoModeProfiles.js` | The shipped `supportedModes` fact per video runtime (#3737), so "which modes does this model support?" has one answer on the registry entry instead of 25+ `runtime === '…'` comparisons. `VIDEO_RUNTIME_MODES` maps each runtime to the base semantic modes its helper's argv can actually render (`mlx_video`/`ltx2` text+image+fflf+extend, `wan22`/`fastvideo` text+image, `minimax_h3` / `minimax_h3_cuda` text+image+fflf off one shared array — also the mode ceiling in `videoGen/modeContract.js`); `VIDEO_BASE_MODES` is the fallback for an unknown (user-added or peer-synced) runtime, deliberately the full base set so a custom model doesn't vanish from every picker. `resolveVideoSupportedModes(entry)` (declared non-empty list always wins) and `applyVideoSupportedModes(list)` — the read-time decoration `mediaModels.js#getVideoModels` applies, so the derived list never lands in `data/media-models.json` where it would read back as a *declared* list no later correction could reach. a2v and the IC-LoRA remix ids are NOT enumerated here: they're ltx2 pipeline capabilities, gated by runtime (`A2V_REQUIRES_LTX2` / `IC_LORA_REQUIRES_LTX2`). |
| `videoReferenceModes.js` | The i2v reference-mode contract (#4874) — what a supplied conditioning image PROMISES. `I2V_REFERENCE_MODES` (`anchor` \| `inspire`) + `I2V_REFERENCE_MODE_OPTIONS` (the label + the promise sentence the UI prints), `I2V_REFERENCE_MODE_RUNTIMES` (only `ltx25` can honor `inspire` — it needs per-image conditioning strength), `INSPIRE_DEFAULT_IMAGE_STRENGTH`, plus `normalizeI2vReferenceMode` / `isDefaultI2vReferenceMode` / `isKnownI2vReferenceMode` / `runtimeSupportsI2vReferenceMode` / `i2vReferenceModeLabel` / `resolveI2vReferenceStrength` and the one rule `i2vReferenceModeViolation({ model, mode, referenceMode, hasFirstImage })` → `{ code, message }` or null. Pure (no `ServerError`) because it is MIRRORED to `client/src/lib/videoReferenceModes.js`; `videoGen/modeContract.js#videoReferenceModeError` wraps it for the route + render boundaries. |
| `videoTextEncoders.js` | Swappable prompt conditioners for local video runtimes. MiniMax H3 reads the *unnormalized* hidden state after Qwen3-VL language layer 49 (layers 50-63, the final norm and `lm_head` are never evaluated), so any checkpoint carrying the same embedding + layers 0-49 + vision tower is a drop-in conditioner — swapping it changes how the model reads a prompt without touching the diffusion weights. `TEXT_ENCODERS_BY_RUNTIME` declares the shipped options per runtime (pinned repo/revision plus an explicit `files` LIST — one repackaged safetensors, or just the shards of an upstream checkpoint that carry parameters the loader actually builds; in code rather than the media-models registry so a stale `data/media-models.json` can't name a file the runner can't map); `videoTextEncoderOptions(model)` returns the TRUE list stock-first (it deliberately does NOT collapse a one-entry runtime to `[]` — that is a presentation rule, and folding it in here would change what the server believes a model supports and empty the "offers …" list in the error; `TextEncoderPicker` owns the hide-when-there-is-no-real-choice check), `isStockTextEncoder(id)` makes absence and the `stock` sentinel the same request, `resolveVideoTextEncoder(model, id)` returns `null` for the stock choice or throws `VIDEO_TEXT_ENCODER_UNSUPPORTED` (with the non-throwing `supportsVideoTextEncoder` + `videoTextEncoderUnsupportedError` split out so the request path can reject before staging uploads), and `downloadableVideoTextEncoders()` (deduped by id — the table is keyed by RUNTIME, so one conditioner can be offered by two) / `downloadableVideoTextEncoder(id)` feed the `/api/video-gen/text-encoders/:id/(download\|repair)` lane. Two loader-mechanics fields exist because a ComfyUI-packaged conditioner is namespaced differently from the HF checkpoint the MLX port matches: `keyPrefixMap` (`model.` → `model.language_model.`, `visual.` → `model.visual.`) is applied to every checkpoint key by `scripts/generate_minimax_h3.py` BEFORE the pinned loader sees it — no fork of the pinned runtime — and `finalNormKey` names where the runner synthesizes a ones-filled `norm.weight` for a checkpoint published without one (correct upstream, since H3 reads the state *before* the norm, but the pinned loader refuses to load with any parameter missing). Both are absent for an UPSTREAM Qwen3-VL-32B checkpoint, which already uses the loader namespace and ships its own norm. A candidate must BE Qwen3-VL-32B (the shim reuses upstream's config/tokenizer/processor) — a different Qwen generation is not a substitute however close its conditioning width looks; see docs/features/video-text-encoders.md. `publicTextEncoderOption(entry)` is the client projection and deliberately drops both, so the UI can't reimplement the remap. The `ltx25` table (#4320) uses a third mechanic, `configOverrides`, because an LTX-2.5 pack's OWN Gemma 4 tower wins over `--gemma` inside the pinned fork: the substitution is a standalone shim directory whose generated `config.json` is the substitute's own with these keys merged over it (only ever the `model_type` label a unified checkpoint gets wrong — never `text_config`/`quantization`), and a candidate must BE Gemma 4 12B at 48 layers / hidden 3840 / vocab 262144 / `k_eq_v`. `verified` gates a substitute out of BOTH lanes (picker AND download) until it has been A/B-rendered against its runtime's stock conditioner — required on every non-built-in entry and fail-closed on absence, so a new entry is unreachable until someone states a verdict; both ltx25 substitutes are `verified: false` today. `declaredVideoTextEncoders()` is the UNFILTERED table for shape/invariant checks only — never the render or download path, and `videoTextEncoderRuntimes()` enumerates the table's runtime keys so parity/shape tests cover every runtime rather than the one that happened to exist when they were written. |
| `providerModels.js` | Provider model resolution sentinels + helpers (`CODEX_CONFIGURED_DEFAULT` / `ANTIGRAVITY_CONFIGURED_DEFAULT` / `GROK_CONFIGURED_DEFAULT` / `KIMI_CONFIGURED_DEFAULT`, `resolveCliModel`, `filterSelectableModels`, Bedrock/OpenCode model mappers, model-flag scan helpers incl. `stripBrokenModelFlags`, `isCodexProvider`, `isKimiProvider`, `isAntigravityProvider`, `isCursorProvider`) plus reasoning-effort helpers for the claude/codex/agy/cursor CLIs (`CLAUDE_EFFORT_LEVELS` / `CODEX_EFFORT_LEVELS` / `ANTIGRAVITY_EFFORT_LEVELS` / `CURSOR_EFFORT_LEVELS` / `EFFORT_LEVELS`, `effortLevelsForProvider`, `resolveCliEffort` — clamps an out-of-range effort to the nearest level the target CLI accepts rather than dropping it, so a value saved against a wider ladder survives a provider switch — `hasEffortFlag`, `buildEffortArgs` — the one emitter of `--effort ` / `-c model_reasoning_effort=`, and deliberately silent for cursor — and `foldCursorEffortIntoModel`, which carries a cursor level inside `--model` as Cursor’s own variant syntax (`gpt-5[effort=max]`) because `cursor-agent` has no `--effort` flag) plus codex startup-arg helpers (`CODEX_EFFORT_KEY`, `CODEX_UPDATE_CHECK_KEY`, `hasCodexUpdateCheckConfig`, `buildCodexStartupArgs` — the one emitter of `-c check_for_update_on_startup=false`, spread by every codex spawn builder to disable the blocking startup update modal) plus `PORTOS_CLI_CONFIG_KEYS` / `isPortosSuppliedConfigKey` — the exhaustive list of `-c =` config keys PortOS injects, read by the `cli-config-invalid` error analyzer to tell a rejected PortOS override apart from a bad line in the user's own CLI config file. |
diff --git a/server/lib/huggingfaceModel.js b/server/lib/huggingfaceModel.js
index cf32d62b66..e35b2263a3 100644
--- a/server/lib/huggingfaceModel.js
+++ b/server/lib/huggingfaceModel.js
@@ -10,9 +10,9 @@
* can load must never land in the registry, because a broken entry wedges the
* model picker (a 400/"Unknown model" the first time the UI tries to use it).
* So GGUF-only repos (llama.cpp/ggml — read nowhere in the image/video
- * pipeline), Wan/HunyuanVideo (BYO-venv runtimes provisioned only via
- * scripts/setup-image-video.sh, not self-service), and anything unclassifiable
- * are refused up front with a typed ServerError the UI can surface.
+ * pipeline), Wan (a BYO-venv runtime not provisioned by this flow),
+ * HunyuanVideo (no supported PortOS runtime), and anything unclassifiable are
+ * refused up front with a typed ServerError the UI can surface.
*
* No try/catch — errors bubble to centralized middleware; domain errors throw
* ServerError.
@@ -70,11 +70,13 @@ export const ADDABLE_VIDEO_RUNTIMES = Object.freeze(['mlx_video', 'ltx2', 'ltx25
// Detect the underlying video runtime family from the classification blob, so
// the allowlist above can refuse the rest symmetrically with the image path.
-// Wan / HunyuanVideo resolve to their (non-addable) runtime ids + the install
-// hint the refusal surfaces; LTX resolves to mlx_video (the safetensors/MLX
-// default). Returns `{ runtime, installHint? }` or null when no video marker
-// matches. A NEW BYO-venv runtime added upstream automatically falls through to
-// "not addable" instead of silently registering as a broken mlx_video entry.
+// Unsupported families still resolve to a non-addable runtime id so they can
+// never be override-laundered into another runner. A provisionable runtime may
+// also carry the install hint the refusal surfaces; Hunyuan deliberately does
+// not because its legacy PortOS runtime has been retired. LTX resolves to
+// mlx_video (the safetensors/MLX default). A NEW BYO-venv runtime added upstream
+// automatically falls through to "not addable" instead of silently registering
+// as a broken mlx_video entry.
const detectVideoRuntime = (blob) => {
if (/\bwan[\s._-]?2|wan-ai\b/.test(blob) || /\bwan2\.\d/.test(blob)) {
return { runtime: 'wan22', installHint: 'INSTALL_WAN22=1 bash scripts/setup-image-video.sh' };
@@ -83,7 +85,7 @@ const detectVideoRuntime = (blob) => {
return { runtime: 'fastvideo', installHint: 'INSTALL_FASTVIDEO=1 bash scripts/setup-image-video.sh' };
}
if (/hunyuan/.test(blob)) {
- return { runtime: 'hunyuan', installHint: 'INSTALL_HUNYUAN=1 bash scripts/setup-image-video.sh' };
+ return { runtime: 'hunyuan' };
}
if (looksLikeLtxVideo(blob)) {
// dgrauet's LTX-2 repos run on the `ltx2` BYO-venv runtime (true keyframe
@@ -287,15 +289,19 @@ export const classifyHfMediaModel = ({ repo, model, kind, runtime, runner, isWin
);
}
- // Refuse a repo whose DETECTED runtime isn't addable (wan22 / hunyuan need a
- // BYO venv) UNCONDITIONALLY — before kind resolution and before any override.
+ // Refuse a repo whose DETECTED runtime isn't addable (wan22 needs a BYO venv;
+ // hunyuan has no supported PortOS runtime) UNCONDITIONALLY — before kind
+ // resolution and before any override.
// The "a bad add can't wedge the picker" guarantee must hold even when the
// caller forces `runtime: 'mlx_video'` on a Hunyuan repo OR `kind: 'image'`
// (which would otherwise route it into the image branch and skip this check
// entirely, persisting a Wan/Hunyuan repo as a bogus image model).
if (detectedVideo && !ADDABLE_VIDEO_RUNTIMES.includes(detectedVideo.runtime)) {
+ const runtimeReason = detectedVideo.installHint
+ ? `needs a dedicated venv (${detectedVideo.installHint}) and can't be added self-service — set it up via the script, then edit data/media-models.json`
+ : 'is not supported by PortOS';
throw new ServerError(
- `HuggingFace repo "${repo}" targets the "${detectedVideo.runtime}" runtime, which needs a dedicated venv (${detectedVideo.installHint}) and can't be added self-service — set it up via the script, then edit data/media-models.json.`,
+ `HuggingFace repo "${repo}" targets the "${detectedVideo.runtime}" runtime, which ${runtimeReason}.`,
{ status: 422, code: 'HF_UNSUPPORTED_RUNTIME' },
);
}
diff --git a/server/lib/huggingfaceModel.test.js b/server/lib/huggingfaceModel.test.js
index 294e45990a..2ac370002e 100644
--- a/server/lib/huggingfaceModel.test.js
+++ b/server/lib/huggingfaceModel.test.js
@@ -53,7 +53,7 @@ describe('classifyHfMediaModel — strict refusal', () => {
})).toThrow(/Hunyuan/);
});
- it('refuses a wan/hunyuan repo even when an addable runtime is forced (no override-laundering)', () => {
+ it('refuses an unsupported Hunyuan repo even when an addable runtime is forced', () => {
// The "a bad add can't wedge the picker" guarantee must hold even under an
// explicit runtime override — forcing mlx_video on a Hunyuan repo would
// register an entry no runtime can load.
@@ -62,7 +62,7 @@ describe('classifyHfMediaModel — strict refusal', () => {
model: hf({ files: ['model.safetensors'], tags: ['hunyuan'] }),
kind: 'video',
runtime: 'mlx_video',
- })).toThrow(/needs a dedicated venv/);
+ })).toThrow(/not supported by PortOS/);
});
it('refuses a wan/hunyuan repo even when kind:image is forced (override cannot skip the video guard)', () => {
diff --git a/server/lib/mediaModelBuckets.js b/server/lib/mediaModelBuckets.js
index 09f4badb7e..7ce179dea5 100644
--- a/server/lib/mediaModelBuckets.js
+++ b/server/lib/mediaModelBuckets.js
@@ -4,7 +4,7 @@
* The video registry has always held exactly two lists of models, and they were
* originally keyed `macos` and `windows`. That naming was never the real axis:
* what separates the two lists is the RUNTIME FAMILY each entry needs — Apple's
- * MLX stack (`mlx_video`, `ltx2`, `ltx25`, `wan22`, `hunyuan`, `minimax_h3`) on
+ * MLX stack (`mlx_video`, `ltx2`, `ltx25`, `wan22`, `fastvideo`, `minimax_h3`) on
* one side, plain torch + CUDA (`ltx_video` via scripts/generate_win.py,
* `minimax_h3_cuda` via diffusers) on the other. Reading the OS off the key
* meant a Linux install was served the MLX list, every entry of which is
diff --git a/server/lib/mediaModels.js b/server/lib/mediaModels.js
index e51a80a365..055c77560f 100644
--- a/server/lib/mediaModels.js
+++ b/server/lib/mediaModels.js
@@ -476,26 +476,6 @@ const DEFAULT_REGISTRY = {
targetRoles: ['high_noise_transformer', 'low_noise_transformer'],
}],
},
- // HunyuanVideo (Tencent) — MLX port at gaurav-nelson/HunyuanVideo_MLX,
- // weights at tencent/HunyuanVideo. 13B params, ~60 GB resident at bf16.
- // Practical only with Gemma 4-bit text encoder (not bf16) + nothing else
- // in unified memory. Provisioned via `INSTALL_HUNYUAN=1 bash
- // scripts/setup-image-video.sh`.
- {
- id: 'hunyuan_video',
- // fp32-only on MPS: fp16/bf16 trip an MPS matmul accumulator-dtype
- // assertion within ~2s of the first forward pass. At 576×1024×121
- // frames × 30 steps that's a 4-8 hr render — marked `deprecated`
- // so it lands in the "Legacy" optgroup. Migration 044 patches
- // existing installs that still have the pre-fix shape.
- name: 'HunyuanVideo (13B — fp32-only on MPS, ~4-8 hr per render)',
- repo: 'tencent/HunyuanVideo',
- runtime: 'hunyuan',
- steps: 30,
- guidance: 6.0,
- precision: 'fp32',
- deprecated: true,
- },
// FastVideo FastMetal models — Hao AI Lab's distilled DMD2 Wan models
// with affine INT8 quantization on Apple Silicon MLX.
{
@@ -971,9 +951,10 @@ const upgradeLegacyCudaLtxRuntime = (list) => {
// Built-in video models that were delivered to installs and have since been
// withdrawn. Dropping an id from DEFAULT_REGISTRY is NOT enough on its own: the
// user's persisted list is what the pickers read, and appendNewlyShippedEntries
-// only ever adds. This is the load-time twin of the retirement migration (247
-// for ltx2_unified) — and it is the load-bearing half, because the registry is
-// cached at import time, BEFORE bootstrapServices() runs migrations, and
+// only ever adds. This is the load-time twin of the retirement migrations (247
+// for ltx2_unified, 315 for hunyuan_video) — and it is the load-bearing half,
+// because the registry is cached at import time, BEFORE bootstrapServices()
+// runs migrations, and
// persistRegistry writes the whole cached object back on the next registry
// edit. Without this the migration's deletion is undone by the same boot that
// applied it.
@@ -995,6 +976,10 @@ export const RETIRED_VIDEO_MODELS = Object.freeze({
shippedRepo: 'notapalindrome/ltx2-mlx-av',
replacement: 'ltx23_distilled_q4',
}),
+ hunyuan_video: Object.freeze({
+ shippedRepo: 'tencent/HunyuanVideo',
+ replacement: 'fastmetal_1_3b_qad',
+ }),
});
const isRetired = (entry) => {
@@ -1113,7 +1098,8 @@ const normalizeRegistry = (parsed) => {
// that persisted their registry before `disclosure` existed pick it up
// here without waiting for the migration, and both paths share the same
// preservation guards (user value wins, forked repo keeps Unknown).
- // dropRetiredEntries is the same arrangement for migration 247, and runs
+ // dropRetiredEntries is the same arrangement for the retirement migrations,
+ // and runs
// FIRST so a withdrawn model isn't handed a disclosure or a Finish edge on
// its way out. sanitizeFinishProfiles runs LAST (after the backfill and
// after the user's own entries are merged in) so an edge that points at a
diff --git a/server/lib/mediaModels.test.js b/server/lib/mediaModels.test.js
index cca73d5ebf..9f24abcc25 100644
--- a/server/lib/mediaModels.test.js
+++ b/server/lib/mediaModels.test.js
@@ -227,14 +227,6 @@ describe('mediaModels registry', () => {
}
});
- it('retires the hunyuan legacy `mode: t2v` field for a text-only contract', async () => {
- const { loadMediaModels, getVideoModels } = await import('./mediaModels.js');
- expect(loadMediaModels().video.mlx.find((m) => m.id === 'hunyuan_video').mode).toBeUndefined();
- const hunyuan = getVideoModels().find((m) => m.id === 'hunyuan_video');
- // The CUDA bucket ships no hunyuan entry, so only assert where it's runnable.
- if (hunyuan) expect(hunyuan.supportedModes).toEqual(['text']);
- });
-
it('is derived on read — never persisted back into the registry file', async () => {
const { loadMediaModels, getVideoModels } = await import('./mediaModels.js');
loadMediaModels();
diff --git a/server/lib/runners.js b/server/lib/runners.js
index 1ff5916f56..953957c44f 100644
--- a/server/lib/runners.js
+++ b/server/lib/runners.js
@@ -111,7 +111,7 @@ export const isMlxVideoLtxLoraCapable = (model) => {
// services/videoGen/runtimes.js and this reads it off the payload (the same
// shape as `lastFrameAnchored`). An undecorated model therefore reads as
// "not capable" — the gate fails closed.
-// The wan22 / hunyuan runtimes (and quantized mlx_video models) have no LoRA
+// The wan22 / fastvideo runtimes (and quantized mlx_video models) have no LoRA
// path, so they return null ("no LoRA support") and the VideoGen picker hides.
export const videoLoraFamily = (model) => {
if (isLtx2FamilyRuntime(model?.runtime) || isMlxVideoLtxLoraCapable(model)) return VIDEO_LORA_FAMILIES.LTX_VIDEO;
diff --git a/server/lib/runners.test.js b/server/lib/runners.test.js
index 1533ade223..c0ea23d971 100644
--- a/server/lib/runners.test.js
+++ b/server/lib/runners.test.js
@@ -81,7 +81,7 @@ describe('VIDEO_LORA_FAMILIES / videoLoraFamily', () => {
// quantized variants are out of scope → null
expect(videoLoraFamily({ runtime: 'mlx_video', id: 'ltx23_distilled_q4', repo: 'notapalindrome/ltx23-mlx-av-q4', name: 'LTX-2.3 Distilled Q4' })).toBe(null);
expect(videoLoraFamily({ runtime: 'wan22' })).toBe(null);
- expect(videoLoraFamily({ runtime: 'hunyuan' })).toBe(null);
+ expect(videoLoraFamily({ runtime: 'fastvideo' })).toBe(null);
expect(videoLoraFamily({})).toBe(null);
expect(videoLoraFamily(null)).toBe(null);
});
@@ -215,7 +215,7 @@ describe('isMiniMaxH3Runtime', () => {
expect(isMiniMaxH3Runtime('minimax_h3_cuda')).toBe(true);
});
- it.each(['mlx_video', 'ltx2', 'wan22', 'hunyuan', 'minimax', '', undefined, null])(
+ it.each(['mlx_video', 'ltx2', 'wan22', 'fastvideo', 'minimax', '', undefined, null])(
'reports %s as not an H3 runtime',
(runtime) => { expect(isMiniMaxH3Runtime(runtime)).toBe(false); },
);
@@ -235,7 +235,7 @@ describe('isLtx2FamilyRuntime', () => {
expect(videoLoraFamily({ runtime: 'ltx25' })).toBe(VIDEO_LORA_FAMILIES.LTX_VIDEO);
});
- it.each(['mlx_video', 'wan22', 'hunyuan', 'minimax_h3', 'ltx', '', undefined, null])(
+ it.each(['mlx_video', 'wan22', 'fastvideo', 'minimax_h3', 'ltx', '', undefined, null])(
'reports %s as not an LTX-2 family runtime',
(runtime) => { expect(isLtx2FamilyRuntime(runtime)).toBe(false); },
);
diff --git a/server/lib/videoContinuity.js b/server/lib/videoContinuity.js
index 22e0592666..f9d67df801 100644
--- a/server/lib/videoContinuity.js
+++ b/server/lib/videoContinuity.js
@@ -72,8 +72,8 @@ export const LATENT_FRAME_STRIDE = 8;
/**
* Runtimes whose helper can condition on a source *video* rather than a still.
* `ltx2` / `ltx25` route to ExtendPipeline.extend_from_video; the `minimax_h3`
- * runtimes, `wan22`,
- * `hunyuan` and `mlx_video` have no equivalent and take the 'frame' path.
+ * runtimes, `wan22`, `fastvideo` and `mlx_video` have no equivalent and take
+ * the 'frame' path.
*
* Kept here rather than in `videoGen/modeContract.js` — that module's "declare
* every runtime gate in one table" rule is about mode/source pairings that
diff --git a/server/lib/videoContinuity.parity.test.js b/server/lib/videoContinuity.parity.test.js
index 9d9cbc8be0..cbfe159d0c 100644
--- a/server/lib/videoContinuity.parity.test.js
+++ b/server/lib/videoContinuity.parity.test.js
@@ -52,7 +52,7 @@ describe('continuation context window — server/client parity', () => {
});
it('agrees on which runtimes can use a window', () => {
- for (const runtime of ['ltx2', 'ltx25', 'mlx_video', 'minimax_h3', 'wan22', 'hunyuan', undefined]) {
+ for (const runtime of ['ltx2', 'ltx25', 'mlx_video', 'minimax_h3', 'wan22', 'fastvideo', undefined]) {
expect(clientSupportsContextWindow({ runtime })).toBe(serverSupportsContextWindow({ runtime }));
}
expect(clientSupportsContextWindow(null)).toBe(serverSupportsContextWindow(null));
diff --git a/server/lib/videoContinuity.test.js b/server/lib/videoContinuity.test.js
index 8c9879e2df..016a79c067 100644
--- a/server/lib/videoContinuity.test.js
+++ b/server/lib/videoContinuity.test.js
@@ -39,7 +39,7 @@ describe('resolveContextFrames', () => {
describe('supportsContextWindow / resolveContinuityStrategy', () => {
it('only ltx2 has an extend pipeline to feed a window to', () => {
expect(supportsContextWindow({ runtime: 'ltx2' })).toBe(true);
- for (const runtime of ['mlx_video', 'minimax_h3', 'wan22', 'hunyuan']) {
+ for (const runtime of ['mlx_video', 'minimax_h3', 'wan22', 'fastvideo']) {
expect(supportsContextWindow({ runtime })).toBe(false);
}
expect(supportsContextWindow(null)).toBe(false);
diff --git a/server/lib/videoDisclosure.js b/server/lib/videoDisclosure.js
index bcfbf7ebb9..cf34184a16 100644
--- a/server/lib/videoDisclosure.js
+++ b/server/lib/videoDisclosure.js
@@ -41,6 +41,9 @@ export const APACHE_2 = { name: 'Apache-2.0', url: 'https://www.apache.org/licen
// something it cannot, so the ltx25 conditioners in lib/videoTextEncoders.js
// disclose THIS rather than whatever their card happens to say.
export const GEMMA_TERMS = { name: 'Gemma Terms of Use', url: 'https://ai.google.dev/gemma/terms' };
+// Compatibility-only disclosure facts retained for migration 237. The model
+// and runtime are no longer shipped, but deleting these would break upgrades
+// whose pending migration still imports the historical disclosure table.
const TENCENT_HUNYUAN_WEIGHTS = {
name: 'Tencent Hunyuan Community License',
url: 'https://huggingface.co/tencent/HunyuanVideo/blob/main/LICENSE',
@@ -69,6 +72,7 @@ const RUNTIME_LICENSE = {
// governs the inference code is diffusers' own.
cuda_video: { name: 'Apache-2.0', url: 'https://github.com/huggingface/diffusers/blob/main/LICENSE' },
minimax_h3_cuda: { name: 'Apache-2.0', url: 'https://github.com/huggingface/diffusers/blob/main/LICENSE' },
+ // Historical runtime license consumed only by the retired model disclosure.
hunyuan: {
name: 'Tencent Hunyuan Community License',
url: 'https://github.com/gaurav-nelson/HunyuanVideo_MLX/blob/main/LICENSE.txt',
@@ -265,6 +269,7 @@ export const VIDEO_MODEL_DISCLOSURES = Object.freeze({
reviewedAt: VIDEO_DISCLOSURE_REVIEWED_AT,
},
},
+ // Historical entry retained for migration 237; not present in the catalog.
hunyuan_video: {
shippedRepo: 'tencent/HunyuanVideo',
disclosure: {
diff --git a/server/lib/videoModeProfiles.js b/server/lib/videoModeProfiles.js
index ecbd0980c8..4d1561c842 100644
--- a/server/lib/videoModeProfiles.js
+++ b/server/lib/videoModeProfiles.js
@@ -45,9 +45,10 @@ export const VIDEO_BASE_MODES = Object.freeze(['text', 'image', 'fflf', 'extend'
* MLX port and the diffusers CUDA path expose the same three, because the
* capability is the checkpoint partition's, not the runner's. Doubles as
* the mode ceiling in videoGen/modeContract.js.
- * - `hunyuan` — the MLX port's `hyvideo.inference` helper takes a prompt and
- * nothing else (see buildHunyuanArgs); text-to-video only. This replaces the
- * legacy `mode: 't2v'` field, which no reader ever consulted.
+ * - `hunyuan` — compatibility-only shape for user-repointed or peer-synced
+ * historical entries. The runtime is retired and renderArgs rejects it,
+ * but retaining text-only prevents those records advertising modes the old
+ * implementation never supported.
*/
// One array, referenced by both H3 runtimes: the modes are the fl2va
// checkpoint partition's, so a second literal would be a copy the comment above
diff --git a/server/lib/videoModeProfiles.test.js b/server/lib/videoModeProfiles.test.js
index ea21cc2584..ce0ffaee6f 100644
--- a/server/lib/videoModeProfiles.test.js
+++ b/server/lib/videoModeProfiles.test.js
@@ -14,7 +14,7 @@ describe('VIDEO_RUNTIME_MODES', () => {
}
});
- it('keeps hunyuan text-only (the retired `mode: t2v` fact)', () => {
+ it('keeps preserved Hunyuan records text-only even though the runtime is retired', () => {
expect(VIDEO_RUNTIME_MODES.hunyuan).toEqual(['text']);
});
@@ -36,7 +36,7 @@ describe('resolveVideoSupportedModes', () => {
it('treats an absent, non-array or empty list as "not declared"', () => {
for (const supportedModes of [undefined, null, [], 'text']) {
- expect(resolveVideoSupportedModes({ runtime: 'hunyuan', supportedModes })).toEqual(['text']);
+ expect(resolveVideoSupportedModes({ runtime: 'wan22', supportedModes })).toEqual(['text', 'image']);
}
});
@@ -49,13 +49,13 @@ describe('resolveVideoSupportedModes', () => {
describe('applyVideoSupportedModes', () => {
it('resolves a list for every entry without mutating the inputs', () => {
const entries = [
- { id: 'a', runtime: 'hunyuan' },
+ { id: 'a', runtime: 'some-future-runtime' },
{ id: 'b', runtime: 'wan22', supportedModes: ['image'] },
{ id: 'c', runtime: 'ltx2' },
];
const out = applyVideoSupportedModes(entries);
expect(out.map((e) => e.supportedModes)).toEqual([
- ['text'],
+ ['text', 'image', 'fflf', 'extend'],
['image'],
['text', 'image', 'fflf', 'extend'],
]);
@@ -63,8 +63,8 @@ describe('applyVideoSupportedModes', () => {
});
it('hands back a copy, never the frozen shared table', () => {
- const [entry] = applyVideoSupportedModes([{ id: 'a', runtime: 'hunyuan' }]);
- expect(entry.supportedModes).not.toBe(VIDEO_RUNTIME_MODES.hunyuan);
+ const [entry] = applyVideoSupportedModes([{ id: 'a', runtime: 'wan22' }]);
+ expect(entry.supportedModes).not.toBe(VIDEO_RUNTIME_MODES.wan22);
});
it('passes through non-entries and a non-array list', () => {
diff --git a/server/lib/videoSpeedProfiles.test.js b/server/lib/videoSpeedProfiles.test.js
index 91699ab682..076c01fef2 100644
--- a/server/lib/videoSpeedProfiles.test.js
+++ b/server/lib/videoSpeedProfiles.test.js
@@ -255,7 +255,7 @@ describe('validateSpeedProfileTable / sanitizeSpeedProfiles', () => {
// SPEEDPROFILE: report would come back — so history would read as a full
// speed-up that never happened.
it('rejects profiles declared on a runtime whose builder cannot emit their flags', () => {
- for (const runtime of ['mlx_video', 'wan22', 'minimax_h3', 'hunyuan', undefined]) {
+ for (const runtime of ['mlx_video', 'wan22', 'minimax_h3', 'fastvideo', undefined]) {
const problems = validateSpeedProfileTable(withProfile(base, { runtime }));
expect(problems).toHaveLength(1);
expect(problems[0].reason).toMatch(/LTX-2-family runtime/);
diff --git a/server/routes/videoGen.test.js b/server/routes/videoGen.test.js
index 77cf5098ef..98ed347989 100644
--- a/server/routes/videoGen.test.js
+++ b/server/routes/videoGen.test.js
@@ -94,11 +94,11 @@ vi.mock('../services/videoGen/local.js', () => ({
// the keyframe-index clamp. Mock returns the real default so the status
// shape test sees a concrete number.
resolveFflfLtx2PixelBudget: vi.fn(() => 704 * 448 * 25),
- // The route gates pythonPath enforcement on this allowlist (ltx2/wan22/
- // hunyuan bring their own venv). Mirror the real export so the
+ // The route gates pythonPath enforcement on this allowlist (BYOV runtimes
+ // bring their own venv). Mirror the real export so the
// "accepts BYOV-runtime when pythonPath missing" case passes and the
// negative case (legacy mlx_video model) still 400s.
- BYOV_VIDEO_RUNTIMES: new Set(['ltx2', 'wan22', 'minimax_h3', 'hunyuan']),
+ BYOV_VIDEO_RUNTIMES: new Set(['ltx2', 'wan22', 'minimax_h3']),
// The route's /status response now surfaces the BYOV runtime list so the
// client can drop its hardcoded copy. Mirror the real shape — only the
// `id` and a couple of UI-display fields are read by /status.
@@ -123,7 +123,6 @@ vi.mock('../services/videoGen/local.js', () => ({
expectedRevision: '2452f0c12edcc8886eebf15772205ce9c417a618',
repoUrl: 'x', repoDir: '/tmp',
},
- hunyuan: { id: 'hunyuan', label: 'HunyuanVideo MLX', venvPython: '/tmp/hunyuan.py', installEnvVar: 'INSTALL_HUNYUAN', repoUrl: 'x', repoDir: '/tmp' },
},
...runtimeProbes,
// /status now surfaces a runtime block (host chip/os + per-runtime versions).
@@ -1576,13 +1575,13 @@ describe('videoGen routes', () => {
// Pre-enqueue config validation: without pythonPath the queue would
// accept the job, return 200/queued, then fail asynchronously over SSE
// and pollute the persisted queue with a doomed entry. Skipped for
- // ltx2/wan22/hunyuan runtimes which bring their own venv (see the
+ // BYOV runtimes which bring their own venv (see the
// BYOV_RUNTIMES allowlist mirrored in services/videoGen/local.js).
it('rejects 400 VIDEO_GEN_NOT_CONFIGURED when pythonPath is missing and the model needs it', async () => {
const settingsMock = await import('../services/settings.js');
settingsMock.getSettings.mockResolvedValueOnce({ imageGen: { local: {} } });
// Override the default `ltx2` mock with a legacy mlx_video runtime so
- // the pythonPath gate actually fires — ltx2/wan22/hunyuan are exempt.
+ // the pythonPath gate actually fires — BYOV runtimes are exempt.
videoGenService.listVideoModels.mockReturnValueOnce([
{ id: 'legacy_mlx', name: 'legacy mlx_video', runtime: 'mlx_video' },
]);
@@ -1592,7 +1591,7 @@ describe('videoGen routes', () => {
expect(mediaJobQueue.enqueueJob).not.toHaveBeenCalled();
});
- // The matching positive case: an ltx2/wan22/hunyuan model bypasses the
+ // The matching positive case: a BYOV model bypasses the
// pythonPath gate because buildArgs resolves its own venv.
it('accepts a BYOV-runtime model (ltx2) when pythonPath is missing', async () => {
const settingsMock = await import('../services/settings.js');
diff --git a/server/services/videoGen/generateVideo.js b/server/services/videoGen/generateVideo.js
index 3fdefa56e9..25e7a98e2c 100644
--- a/server/services/videoGen/generateVideo.js
+++ b/server/services/videoGen/generateVideo.js
@@ -168,7 +168,7 @@ export async function generateVideo({ pythonPath, prompt, negativePrompt = '', m
// work. Internal producers and persisted/retried jobs bypass route
// preparation, so silently dropping one of these inputs here would render a
// materially different video than the caller requested. Ungated runtimes
- // (ltx2 / mlx_video / hunyuan) fall through untouched.
+ // (ltx2 / mlx_video) fall through untouched.
//
// Promote before checking: the route sets both fields, but a direct caller
// that only staged `uploadedTempPath` would otherwise pass the mode guard and
@@ -303,10 +303,10 @@ export async function generateVideo({ pythonPath, prompt, negativePrompt = '', m
}
// Only require the legacy mlx_video pythonPath when the chosen runtime
- // actually uses it. ltx2/wan22/hunyuan resolve their own venv path inside
+ // actually uses it. BYOV runtimes resolve their own venv path inside
// buildArgs — gating them on the unrelated mlx_video setting locks users
- // out of the runtimes they just installed via INSTALL_WAN22 / INSTALL_LTX2
- // / INSTALL_HUNYUAN. Routes/videoGen.js reads the same module-level set.
+ // out of the runtimes they just installed. Routes/videoGen.js reads the same
+ // module-level set.
if (!pythonPath && !BYOV_VIDEO_RUNTIMES.has(model.runtime)) {
throw new ServerError('Python path not configured — set it in Settings > Image Gen', { status: 400, code: 'VIDEO_GEN_NOT_CONFIGURED' });
}
diff --git a/server/services/videoGen/generateVideoHelpers.js b/server/services/videoGen/generateVideoHelpers.js
index 378b584d90..5c5fe01e2d 100644
--- a/server/services/videoGen/generateVideoHelpers.js
+++ b/server/services/videoGen/generateVideoHelpers.js
@@ -189,8 +189,8 @@ export function makeVideoGenLineHandler({ job, jobId, pythonNoiseRe }) {
// The legacy "treat every STAGE: as step:" parse mangled heartbeat
// lines: parts[3]='20s' → parseInt=20, parts[4]=undefined → total=1, so
// a download-clip heartbeat broadcast progress=20.0 (= 2000%) to the UI.
- // Normalize tag case — generate_ltx2.py emits `STEP:` (uppercase),
- // generate_hunyuan.py emits `step:` and `heartbeat:` (lowercase).
+ // Normalize tag case because BYOV helpers are not required to agree on
+ // capitalization.
const tag = (parts[2] || '').toLowerCase();
if (tag === 'heartbeat') {
// Surface as a status message; the activity emit above already
diff --git a/server/services/videoGen/local.test.js b/server/services/videoGen/local.test.js
index 3cd545557e..bebeb18eb1 100644
--- a/server/services/videoGen/local.test.js
+++ b/server/services/videoGen/local.test.js
@@ -109,6 +109,10 @@ vi.mock('../../lib/mediaModels.js', async () => {
{ id: 'ltx23_unified', name: 'LTX-2.3 Unified Beta', runtime: 'mlx_video', repo: 'notapalindrome/ltx23-mlx-av', steps: 25, guidance: 3.0 },
// quantized mlx_video model — NOT LoRA-capable (out of scope).
{ id: 'ltx23_distilled_q4', name: 'LTX-2.3 Distilled Q4', runtime: 'mlx_video', repo: 'notapalindrome/ltx23-mlx-av-q4', steps: 25, guidance: 3.0 },
+ // Compatibility fixture: the shipped profile is gone, but a user-repointed
+ // or peer-synced historical entry must fail closed instead of falling into
+ // the generic MLX/CUDA runner.
+ { id: 'custom_hunyuan', name: 'Custom historical Hunyuan', runtime: 'hunyuan', repo: 'example-org/custom-video-runtime', supportedModes: ['text'], steps: 30, guidance: 6 },
{
id: 'minimax_h3_8bit', name: 'MiniMax H3 MLX 8-bit', runtime: 'minimax_h3',
repo: 'pipenetwork/MiniMax-H3-MLX-8bit',
@@ -1501,6 +1505,22 @@ describe('generateVideo — LTX audio-reactive conditioning', () => {
});
});
+describe('generateVideo — retired runtime guard', () => {
+ it('refuses a preserved Hunyuan entry before choosing a fallback runner', async () => {
+ await expect(generateVideo({
+ jobId: 'retired-hunyuan-runtime',
+ pythonPath: '/usr/bin/python3',
+ modelId: 'custom_hunyuan',
+ prompt: 'a quiet street at dusk',
+ width: 512,
+ height: 512,
+ numFrames: 25,
+ fps: 24,
+ mode: 'text',
+ })).rejects.toMatchObject({ status: 400, code: 'VIDEO_RUNTIME_RETIRED' });
+ });
+});
+
describe('generateVideo — PORTOS_T2V_TWO_STAGE arg threading', () => {
afterEach(() => { delete process.env.PORTOS_T2V_TWO_STAGE; });
diff --git a/server/services/videoGen/modeContract.js b/server/services/videoGen/modeContract.js
index 9f6889d876..f1f9dcab2f 100644
--- a/server/services/videoGen/modeContract.js
+++ b/server/services/videoGen/modeContract.js
@@ -139,8 +139,8 @@ const isPresent = (value) => (Array.isArray(value) ? value.length > 0 : Boolean(
/**
* The one video mode/source contract, driven by `model.supportedModes`. Returns
* a ServerError to throw, or null when the request is legal (including for a
- * runtime that declares no row — ltx2, mlx_video and hunyuan gate their modes
- * through their own helpers).
+ * runtime that declares no row — ltx2 and mlx_video gate their modes through
+ * their own helpers).
*
* `supportedModes` comes off the registry entry (resolved from the runtime table
* when the entry declares none) so the picker and the API agree even on an
diff --git a/server/services/videoGen/modeContract.test.js b/server/services/videoGen/modeContract.test.js
index 4c0ac6c217..bbf8a5b7cb 100644
--- a/server/services/videoGen/modeContract.test.js
+++ b/server/services/videoGen/modeContract.test.js
@@ -16,7 +16,7 @@ describe('videoModeContractError — shared gate', () => {
expect([...VIDEO_MODE_GATED_RUNTIMES].sort()).toEqual(['fastvideo', 'minimax_h3', 'minimax_h3_cuda', 'wan22']);
});
- it.each(['ltx2', 'mlx_video', 'hunyuan', undefined])('leaves the %s runtime ungated', (runtime) => {
+ it.each(['ltx2', 'mlx_video', undefined])('leaves the %s runtime ungated', (runtime) => {
expect(videoModeContractError({
model: { runtime, name: 'Example Model' }, mode: 'extend', hasFirstImage: true,
})).toBeNull();
diff --git a/server/services/videoGen/prepareParams.js b/server/services/videoGen/prepareParams.js
index 58ab170f7c..7ec1a3dfa2 100644
--- a/server/services/videoGen/prepareParams.js
+++ b/server/services/videoGen/prepareParams.js
@@ -327,8 +327,8 @@ export async function prepareVideoGenParams({ body, uploads, localOnlyParamKeys
throw videoTextEncoderUnsupportedError(effectiveModel, body.textEncoderId);
}
// Reject up-front when the local python isn't configured AND the model's
- // runtime needs it. ltx2/wan22/hunyuan bring their own venv (resolved
- // inside buildArgs), so they must NOT be blocked by the legacy mlx_video
+ // runtime needs it. BYOV runtimes bring their own venv (resolved inside
+ // buildArgs), so they must NOT be blocked by the legacy mlx_video
// pythonPath setting. Without this gate, the queue would happily accept
// a job that's known to fail and only surface it asynchronously on SSE,
// polluting the persisted queue with a doomed entry. The allowlist is
@@ -958,7 +958,7 @@ async function resolvePreparedParams({
// `minimax_h3` applies them at runtime, but only if the installed checkout
// passes PortOS's quant-aware adapter probe — listVideoModels() decorates that
// result as `runtimeLoraCapable`, which videoLoraFamily() reads.
- // videoLoraFamily() returns null for everything else (wan22 / hunyuan /
+ // videoLoraFamily() returns null for everything else (wan22 / fastvideo /
// quantized mlx_video) — reject up-front so a bad modelId can't enqueue a
// doomed job that only fails in the worker.
if (loras && effectiveModel) {
diff --git a/server/services/videoGen/prepareParams.test.js b/server/services/videoGen/prepareParams.test.js
index d5ca28832b..e2bc5b5bcf 100644
--- a/server/services/videoGen/prepareParams.test.js
+++ b/server/services/videoGen/prepareParams.test.js
@@ -19,7 +19,7 @@ vi.mock('./local.js', () => ({
listVideoModels: vi.fn(() => [{ id: 'ltx2_unified', name: 'LTX-2 Unified', runtime: 'ltx2' }]),
defaultVideoModelId: vi.fn(() => 'ltx2_unified'),
loadHistory: vi.fn(async () => []),
- BYOV_VIDEO_RUNTIMES: new Set(['ltx2', 'wan22', 'minimax_h3', 'hunyuan']),
+ BYOV_VIDEO_RUNTIMES: new Set(['ltx2', 'wan22', 'minimax_h3']),
DEFAULT_NUM_FRAMES: 121,
}));
diff --git a/server/services/videoGen/renderArgs.js b/server/services/videoGen/renderArgs.js
index bfbbbb7d19..2e93a81a05 100644
--- a/server/services/videoGen/renderArgs.js
+++ b/server/services/videoGen/renderArgs.js
@@ -37,9 +37,6 @@ import {
MINIMAX_H3_CUDA_VENV_PYTHON,
MINIMAX_H3_CUDA_HELPER_SCRIPT,
MINIMAX_H3_CUDA_OFFLOAD_PROFILES,
- HUNYUAN_VENV_PYTHON,
- HUNYUAN_HELPER_SCRIPT,
- HUNYUAN_REPO_DIR,
FASTVIDEO_VENV_PYTHON,
FASTVIDEO_HELPER_SCRIPT,
FASTVIDEO_REPO_DIR,
@@ -770,44 +767,6 @@ const buildMiniMaxH3CudaArgs = ({ model, prompt, negativePrompt, width, height,
return { bin: MINIMAX_H3_CUDA_VENV_PYTHON, args };
};
-// Allowed precision tokens for runners that expose dtype as a CLI flag. The
-// Python side already gates argparse with `choices=`, but a bogus value in
-// data/media-models.json would otherwise reach the helper and surface as a
-// less-friendly "invalid choice" inside a Python traceback — failing here
-// gives a stable PortOS error code the route + client error path knows.
-const VIDEO_PRECISIONS = Object.freeze(['fp16', 'bf16', 'fp32']);
-
-// Build args for the HunyuanVideo MLX helper. Calls hyvideo.inference
-// directly (see scripts/generate_hunyuan.py) so the steps / guidance /
-// precision flags actually take effect — upstream's sample_video_mps.py
-// silently hardcoded them.
-const buildHunyuanArgs = ({ model, prompt, negativePrompt, width, height, numFrames, steps, guidance, seed, outputPath }) => {
- assertByovRuntimeInstalled('hunyuan');
- const precision = model.precision || 'fp16';
- if (!VIDEO_PRECISIONS.includes(precision)) {
- throw new ServerError(
- `Invalid precision "${precision}" on model "${model.id}" — expected one of ${VIDEO_PRECISIONS.join(', ')}`,
- { status: 500, code: 'VIDEO_MODEL_MISCONFIGURED' },
- );
- }
- const args = [
- HUNYUAN_HELPER_SCRIPT,
- '--repo-dir', HUNYUAN_REPO_DIR,
- '--model-repo', model.repo,
- '--prompt', prompt,
- '--width', String(width),
- '--height', String(height),
- '--num-frames', String(numFrames),
- '--steps', String(steps),
- '--guidance', String(guidance ?? 6.0),
- '--seed', String(seed),
- '--precision', precision,
- '--output', outputPath,
- ];
- if (negativePrompt) args.push('--negative-prompt', negativePrompt);
- return { bin: HUNYUAN_VENV_PYTHON, args };
-};
-
export const buildArgs = ({ pythonPath, modelId, model, wanModelPath, wanRequiredWeights, ltxModelPath, prompt, negativePrompt, width, height, numFrames, fps, steps, stage2Steps, guidance, seed, tiling, disableAudio, sourceImagePath, lastImagePath, keyframes, extendFromVideoPath, audioFilePath, audioStartSec, mode, imageStrength, i2vReferenceMode, textEncoderRepo, textEncoder, outputPath, previewDir, loras, icReferencePaths, icLoraWeightPath, icStrength, icAttentionStrength, icSkipStage2, speedProfile }) => {
// Reference-mode promise (#4874) — checked HERE rather than inside
// buildLtx2Args because every runtime reaches this function and only one can
@@ -874,8 +833,15 @@ export const buildArgs = ({ pythonPath, modelId, model, wanModelPath, wanRequire
if (model.runtime === 'minimax_h3_cuda') {
return buildMiniMaxH3CudaArgs({ model, prompt, negativePrompt, width, height, numFrames, fps, steps, seed, sourceImagePath, lastImagePath, keyframes, extendFromVideoPath, audioFilePath, audioStartSec, icReferencePaths, mode, tiling, disableAudio, outputPath });
}
+ // Migration 315 removes the shipped Hunyuan profile, but a user-repointed
+ // or peer-synced historical entry may still declare its retired runtime.
+ // Fail closed instead of falling through to a legacy MLX/CUDA helper that
+ // cannot load the checkpoint.
if (model.runtime === 'hunyuan') {
- return buildHunyuanArgs({ model, prompt, negativePrompt, width, height, numFrames, steps, guidance, seed, outputPath });
+ throw new ServerError(
+ `The "hunyuan" runtime was retired — model "${modelId}" can no longer be rendered. Pick a supported model such as FastMetal in data/media-models.json.`,
+ { status: 400, code: 'VIDEO_RUNTIME_RETIRED' },
+ );
}
if (Array.isArray(keyframes) && keyframes.length >= 2) {
throw new ServerError(
diff --git a/server/services/videoGen/runtimes.js b/server/services/videoGen/runtimes.js
index f3e10b8e35..c8a85eaa3a 100644
--- a/server/services/videoGen/runtimes.js
+++ b/server/services/videoGen/runtimes.js
@@ -85,14 +85,6 @@ export const MINIMAX_H3_CUDA_OFFLOAD_PROFILES = Object.freeze([
'auto', 'bf16', 'int8-stream', 'int8-lean',
]);
-// HunyuanVideo MLX runtime — gaurav-nelson/HunyuanVideo_MLX cloned at
-// ~/.portos/hunyuan-video-mlx/. ~60 GB resident at bf16 so practical only
-// with the 4-bit Gemma text encoder + everything else evicted. Provisioned
-// via `INSTALL_HUNYUAN=1 bash scripts/setup-image-video.sh`.
-export const HUNYUAN_VENV_PYTHON = join(homedir(), '.portos', 'hunyuan-video-mlx', '.venv', 'bin', 'python3');
-export const HUNYUAN_HELPER_SCRIPT = join(PATHS.root, 'scripts', 'generate_hunyuan.py');
-export const HUNYUAN_REPO_DIR = join(homedir(), '.portos', 'hunyuan-video-mlx');
-
// FastVideo MLX runtime — Hao AI Lab's FastVideo / FastMetal Apple Silicon framework.
export const FASTVIDEO_VENV_PYTHON = join(homedir(), '.portos', 'fastvideo', '.venv', 'bin', 'python3');
export const FASTVIDEO_HELPER_SCRIPT = join(PATHS.root, 'scripts', 'generate_fastvideo.py');
@@ -154,7 +146,7 @@ export const BYOV_RUNTIME_INFO = Object.freeze({
// and add deltas in the forward pass (fusing into packed-uint32 weights is
// not possible). The probe exercises the local adapter against the pinned
// runtime without loading the model. Absence of this key means "runtime can
- // never take LoRAs", which is the correct answer for wan22 / hunyuan.
+ // never take LoRAs", which is the correct answer for wan22.
loraProbeArgs: [MINIMAX_H3_LORA_PROBE_SCRIPT, MINIMAX_H3_REPO_DIR],
fingerprintPackages: ['mlx', 'mlx-metal', 'mlx-vlm', 'transformers', 'huggingface-hub'],
},
@@ -191,20 +183,6 @@ export const BYOV_RUNTIME_INFO = Object.freeze({
// Mirror scripts/generate_minimax_h3_cuda.py's emit_runtime_fingerprint list.
fingerprintPackages: ['torch', 'diffusers', 'transformers', 'torchao', 'accelerate', 'huggingface-hub'],
},
- hunyuan: {
- id: 'hunyuan',
- label: 'HunyuanVideo MLX',
- venvPython: HUNYUAN_VENV_PYTHON,
- repoDir: HUNYUAN_REPO_DIR,
- installEnvVar: 'INSTALL_HUNYUAN',
- repoUrl: 'https://github.com/gaurav-nelson/HunyuanVideo_MLX',
- // `hyvideo` isn't pip-installed — mirror the runner's sys.path prepend so
- // the probe walks the same transitive import chain (loguru, diffusers, …).
- importProbe: `import sys; sys.path.insert(0, ${JSON.stringify(HUNYUAN_REPO_DIR)}); import hyvideo.inference`,
- // Distributions the /status runtime-fingerprint probe resolves versions for
- // (must match scripts/generate_hunyuan.py's emit_runtime_fingerprint call).
- fingerprintPackages: ['torch', 'diffusers', 'transformers', 'mlx'],
- },
wan22: {
id: 'wan22',
label: 'Wan 2.2 MLX',
@@ -520,8 +498,8 @@ export async function isByovRuntimeCurrent(runtimeId) {
// Throws the same shape the per-runtime buildArgs used to throw inline — a
// 500 with a stable runtime-specific code the route layer and tests already
-// match against. The error codes are LTX2_VENV_MISSING / WAN22_VENV_MISSING
-// / HUNYUAN_VENV_MISSING; keep `runtimeId.toUpperCase()` to preserve them.
+// match against. Keep `runtimeId.toUpperCase()` so every runtime retains its
+// stable, specific error code.
export function assertByovRuntimeInstalled(runtimeId) {
const info = BYOV_RUNTIME_INFO[runtimeId];
if (!info) return;
diff --git a/server/services/videoGen/runtimes.test.js b/server/services/videoGen/runtimes.test.js
index 60e67ff811..ebb9cab201 100644
--- a/server/services/videoGen/runtimes.test.js
+++ b/server/services/videoGen/runtimes.test.js
@@ -71,6 +71,13 @@ beforeEach(() => {
runtimeMocks.spawn.mockReset();
});
+describe('retired runtime filtering', () => {
+ it('does not advertise legacy Hunyuan support to the video UI', () => {
+ expect(BYOV_RUNTIME_INFO).not.toHaveProperty('hunyuan');
+ expect(BYOV_VIDEO_RUNTIMES.has('hunyuan')).toBe(false);
+ });
+});
+
describe('isPinnedSourceStatusClean', () => {
it('accepts the exact revision when the scoped source package is clean', () => {
expect(isPinnedSourceStatusClean([
@@ -250,7 +257,7 @@ describe('MiniMax H3 LoRA capability', () => {
expect(runtimeMocks.spawn).toHaveBeenCalledTimes(1);
});
- it.each(['ltx2', 'ltx25', 'wan22', 'hunyuan'])('never probes %s, which has no LoRA runtime path', async (runtime) => {
+ it.each(['ltx2', 'ltx25', 'wan22'])('never probes %s, which has no LoRA runtime path', async (runtime) => {
await expect(resolveByovRuntimeLoraCapable(runtime)).resolves.toBe(false);
expect(byovRuntimeLoraCapable(runtime)).toBe(false);
expect(runtimeMocks.spawn).not.toHaveBeenCalled();
@@ -270,7 +277,7 @@ describe('modelAnchorsLastFrame', () => {
['minimax_h3_cuda', true],
['mlx_video', false],
['wan22', false],
- ['hunyuan', false],
+ ['fastvideo', false],
])('reports %s as %s', (runtime, anchored) => {
expect(modelAnchorsLastFrame({ runtime })).toBe(anchored);
});
@@ -346,7 +353,7 @@ describe('minimax_h3_cuda runtime registration', () => {
it('declares no revision pin or LoRA probe — it runs distributions, not a checkout', () => {
// `expectedRevision`/`sourcePath` drive the clean-checkout gate, which has
// nothing to verify here; `loraProbeArgs` absent is the correct "this
- // runtime can never take LoRAs", matching wan22 / hunyuan.
+ // runtime can never take LoRAs", matching wan22 / fastvideo.
expect(info.expectedRevision).toBeUndefined();
expect(info.sourcePath).toBeUndefined();
expect(info.loraProbeArgs).toBeUndefined();
@@ -363,8 +370,8 @@ describe('minimax_h3_cuda runtime registration', () => {
// The JS list exists so the server can reject a bad registry `offloadProfile`
// with a stable code instead of an opaque non-zero child exit — which only
// works while it agrees with the argparse `choices=` that actually enforces it.
-// Hand-synced across a language boundary is the established shape here (see
-// VIDEO_PRECISIONS), so pin it rather than leave the two free to drift.
+// Hand-synced across a language boundary is the established shape here, so pin
+// it rather than leave the two free to drift.
describe('MiniMax H3 CUDA offload profiles', () => {
it('matches OFFLOAD_PROFILES in the Python runner', () => {
const runner = readFileSync(
diff --git a/server/services/videoGen/spawnWatch.js b/server/services/videoGen/spawnWatch.js
index a98cfa4019..8d5b6fc208 100644
--- a/server/services/videoGen/spawnWatch.js
+++ b/server/services/videoGen/spawnWatch.js
@@ -668,8 +668,8 @@ export async function spawnAndWatchVideo({
// of the parent shell's PYTHONPATH. Setting to `undefined` in a spread does
// NOT unset the var — Node coerces it to the literal string "undefined" —
// so build the env explicitly and `delete`.
- // Build the complete HF child env so the Wan 2.2 / HunyuanVideo
- // python helpers can authenticate snapshot_download() against gated repos
+ // Build the complete HF child env so BYOV Python helpers can authenticate
+ // snapshot_download() against gated repos
// (mirrors the imageGen child-spawn pattern). LTX-2 doesn't currently use
// a gated repo, but the merge is harmless when no token is configured.
childEnv = runtimeIsCacheOnly(model.runtime)