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)