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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/pages/MediaModels.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ export default function MediaModels() {
</p>
)}
<p className="text-[11px] text-gray-600">
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.
</p>
</form>
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/VideoGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 0 additions & 23 deletions data.reference/media-models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
4 changes: 2 additions & 2 deletions scripts/_runner_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate_av_lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
389 changes: 0 additions & 389 deletions scripts/generate_hunyuan.py

This file was deleted.

2 changes: 1 addition & 1 deletion scripts/generate_ltx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions scripts/migrations/315-retire-hunyuan-video.js
Original file line number Diff line number Diff line change
@@ -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}`);
},
};
106 changes: 106 additions & 0 deletions scripts/migrations/315-retire-hunyuan-video.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 2 additions & 60 deletions scripts/setup-image-video.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading