diff --git a/data.reference/media-models.json b/data.reference/media-models.json index 6d078c476..52d82f756 100644 --- a/data.reference/media-models.json +++ b/data.reference/media-models.json @@ -692,6 +692,87 @@ "reviewedAt": "2026-08-30" } }, + { + "id": "ltx25_cuda_distilled", + "name": "LTX-2.5 CUDA Distilled (joint video + audio, ~72 GB download, streamed)", + "repo": "Lightricks/LTX-2.5", + "revision": "bf86adedf518142442575d1ce2e767b7d01c8c76", + "repoFiles": [ + "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors", + "text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors", + "vae/ltx-2.5-video-vae-bf16.safetensors", + "vae/ltx-2.5-audio-vae-bf16.safetensors", + "model_patches/ltx-2.5-duration-head-bf16.safetensors", + "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors" + ], + "runtime": "ltx25_cuda", + "supportedModes": ["text", "image"], + "defaultFrames": 121, + "resolutionStep": 64, + "fpsOptions": [24], + "steps": 8, + "guidance": 1, + "samplerLocked": true, + "samplerNote": "LTX-2.5 Distilled uses the official fixed 8-step, CFG-free schedule.", + "supportsNegativePrompt": false, + "supportsTiling": false, + "supportsDisableAudio": true, + "requiresHfToken": true, + "hardwareRequirements": { + "minMemoryGb": 64, + "minVramGb": 16, + "minCudaComputeCapability": 8 + }, + "disclosure": { + "modelCardUrl": "https://huggingface.co/Lightricks/LTX-2.5", + "weightsLicense": { + "name": "LTX-2.x Community License", + "url": "https://github.com/Lightricks/LTX-2/blob/main/LICENSE.md" + }, + "runtimeLicense": { + "name": "Apache-2.0", + "url": "https://github.com/Lightricks/LTX-2/blob/v1.2.0/LICENSE" + }, + "estimatedDownloadGb": 72.1, + "reviewedAt": "2026-08-30" + } + }, + { + "id": "wan22_cuda_ti2v_5b", + "name": "Wan 2.2 TI2V 5B CUDA (high quality, ~34 GB download, text-to-video)", + "repo": "Wan-AI/Wan2.2-TI2V-5B-Diffusers", + "revision": "b8fff7315c768468a5333511427288870b2e9635", + "runtime": "wan22_cuda", + "supportedModes": ["text"], + "defaultWidth": 1280, + "defaultHeight": 704, + "resolutionStep": 16, + "defaultFrames": 121, + "frameStride": 4, + "fpsOptions": [24], + "steps": 50, + "guidance": 5, + "supportsNegativePrompt": true, + "supportsTiling": false, + "hardwareRequirements": { + "minMemoryGb": 32, + "minVramGb": 24, + "minCudaComputeCapability": 8 + }, + "disclosure": { + "modelCardUrl": "https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B-Diffusers", + "weightsLicense": { + "name": "Apache-2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + }, + "runtimeLicense": { + "name": "Apache-2.0", + "url": "https://github.com/huggingface/diffusers/blob/main/LICENSE" + }, + "estimatedDownloadGb": 34.2, + "reviewedAt": "2026-08-30" + } + }, { "id": "minimax_h3_cuda", "name": "MiniMax H3 CUDA int8 (joint video + audio, ~144 GB download, 24 GB VRAM + 96 GB RAM)", diff --git a/scripts/generate_ltx25_cuda.py b/scripts/generate_ltx25_cuda.py new file mode 100644 index 000000000..65f4c35d6 --- /dev/null +++ b/scripts/generate_ltx25_cuda.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Cache-only LTX-2.5 distilled video+audio runner for NVIDIA CUDA. + +The Video Gen UI owns runtime installation and every Hugging Face download. +This helper resolves the pinned split checkpoint only from the local cache, +streams model blocks from disk on consumer GPUs, and writes one MP4. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _runner_common import emit_runtime_fingerprint, establish_process_group, heartbeat # noqa: E402 + + +MODEL_FILES = { + "transformer": "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors", + "text_encoder": "text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors", + "video_vae": "vae/ltx-2.5-video-vae-bf16.safetensors", + "audio_vae": "vae/ltx-2.5-audio-vae-bf16.safetensors", + "duration_head": "model_patches/ltx-2.5-duration-head-bf16.safetensors", + "upsampler": "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", +} + + +def log(message: str) -> None: + print(message, file=sys.stderr, flush=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-repo", required=True) + parser.add_argument("--model-revision", required=True) + parser.add_argument("--repo-file", action="append", default=[]) + parser.add_argument("--prompt", required=True) + parser.add_argument("--negative-prompt") + parser.add_argument("--width", type=int, required=True) + parser.add_argument("--height", type=int, required=True) + parser.add_argument("--num-frames", type=int, required=True) + parser.add_argument("--fps", type=float, required=True) + parser.add_argument("--steps", type=int, default=8) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--image") + parser.add_argument("--image-strength", type=float, default=1.0) + parser.add_argument("--disable-audio", action="store_true") + parser.add_argument("--output", required=True) + return parser.parse_args() + + +def validate_args(args: argparse.Namespace) -> None: + missing = sorted(set(MODEL_FILES.values()) - set(args.repo_file)) + if missing: + raise SystemExit(f"LTX-2.5 model entry is missing required repo files: {', '.join(missing)}") + if args.width % 64 or args.height % 64: + raise SystemExit("The two-stage LTX-2.5 pipeline requires width and height divisible by 64.") + if args.num_frames < 9 or args.num_frames % 8 != 1: + raise SystemExit("LTX-2.5 num-frames must be at least 9 and satisfy frames % 8 == 1.") + if args.steps != 8: + raise SystemExit("The distilled LTX-2.5 schedule is fixed at 8 steps.") + if not 0 <= args.image_strength <= 1: + raise SystemExit("image-strength must be between 0 and 1.") + + +def resolve_snapshot(args: argparse.Namespace) -> Path: + from huggingface_hub import snapshot_download + + try: + root = snapshot_download( + repo_id=args.model_repo, + revision=args.model_revision, + allow_patterns=args.repo_file, + local_files_only=True, + ) + except Exception as exc: + raise RuntimeError( + "The pinned LTX-2.5 files are not complete in the Hugging Face cache. " + "Use Download or Repair on the Video Gen page." + ) from exc + snapshot = Path(root) + unresolved = [relative for relative in MODEL_FILES.values() if not (snapshot / relative).is_file()] + if unresolved: + raise RuntimeError( + "The pinned LTX-2.5 snapshot is incomplete: " + ", ".join(unresolved) + ) + return snapshot + + +def main() -> None: + establish_process_group() + args = parse_args() + validate_args(args) + log("STAGE:resolve-cache") + snapshot = resolve_snapshot(args) + + # LTX's documented allocator setting reduces fragmentation on cards that + # sit close to the model's supported VRAM floor. It must be set before + # importing torch so CUDA reads it during allocator initialization. + os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") + import torch + from ltx_core.model.video_vae import AUTO_TILING, get_video_chunks_number + from ltx_core.quantization.fp8_cast import build_policy as build_fp8_cast_policy + from ltx_pipelines.distilled import DistilledPipeline + from ltx_pipelines.utils.args import ImageConditioningInput + from ltx_pipelines.utils.media_io import encode_video + from ltx_pipelines.utils.model_paths import ModelPaths + from ltx_pipelines.utils.types import OffloadMode + + if not torch.cuda.is_available(): + raise RuntimeError("LTX-2.5 CUDA needs a visible NVIDIA device. Repair the runtime from Video Gen.") + emit_runtime_fingerprint( + "ltx25_cuda", + ["torch", "ltx-core", "ltx-pipelines", "transformers", "accelerate", "huggingface-hub"], + ) + paths = {key: str(snapshot / relative) for key, relative in MODEL_FILES.items()} + model_paths = ModelPaths.from_split( + transformer_path=paths["transformer"], + text_encoder_path=paths["text_encoder"], + video_vae_path=paths["video_vae"], + audio_vae_path=paths["audio_vae"], + duration_head_path=paths["duration_head"], + ) + + log("STATUS:LTX-2.5 CUDA disk-streamed loading (FP8 transformer cache)") + log("STAGE:load-pipeline") + with heartbeat("ltx25-cuda-load"): + pipe = DistilledPipeline( + model_paths=model_paths, + spatial_upsampler_path=paths["upsampler"], + loras=[], + device=torch.device("cuda"), + quantization=build_fp8_cast_policy(paths["transformer"]), + # CPU mode pins a model-sized prefetch buffer. The 24 GB VRAM / + # 32 GB system-RAM tier can exhaust that buffer while Gemma is + # resident; DISK is upstream's lowest-memory streaming mode. + offload_mode=OffloadMode.DISK, + ) + images = [] + if args.image: + images.append(ImageConditioningInput(args.image, 0, args.image_strength)) + log("STAGE:inference") + with heartbeat("ltx25-cuda-inference"): + video, audio, resolved_frames, tiling = pipe( + prompt=args.prompt, + seed=args.seed, + height=args.height, + width=args.width, + num_frames=args.num_frames, + frame_rate=args.fps, + images=images, + tiling_config=AUTO_TILING, + ) + if args.disable_audio: + audio = None + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + log("STAGE:mux") + encode_video( + video=video, + fps=int(args.fps), + audio=audio, + output_path=str(output), + video_chunks_number=int(get_video_chunks_number(resolved_frames, tiling)), + ) + if not output.is_file(): + raise RuntimeError(f"LTX-2.5 completed but did not write {output}.") + log(f"STATUS:LTX-2.5 saved {output.name} ({resolved_frames} frames)") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_wan22_cuda.py b/scripts/generate_wan22_cuda.py new file mode 100644 index 000000000..84dd68d51 --- /dev/null +++ b/scripts/generate_wan22_cuda.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Cache-only Wan 2.2 TI2V 5B text-to-video runner for NVIDIA CUDA.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _runner_common import emit_runtime_fingerprint, establish_process_group, heartbeat # noqa: E402 + + +def log(message: str) -> None: + print(message, file=sys.stderr, flush=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-repo", required=True) + parser.add_argument("--model-revision", required=True) + parser.add_argument("--prompt", required=True) + parser.add_argument("--negative-prompt", default="") + parser.add_argument("--width", type=int, required=True) + parser.add_argument("--height", type=int, required=True) + parser.add_argument("--num-frames", type=int, required=True) + parser.add_argument("--fps", type=float, required=True) + parser.add_argument("--steps", type=int, required=True) + parser.add_argument("--guidance", type=float, required=True) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--output", required=True) + return parser.parse_args() + + +def validate_args(args: argparse.Namespace) -> None: + if args.width % 16 or args.height % 16: + raise SystemExit("Wan 2.2 width and height must be divisible by 16.") + if args.num_frames < 5 or (args.num_frames - 1) % 4: + raise SystemExit("Wan 2.2 frame count must satisfy 4n+1.") + if args.steps < 1: + raise SystemExit("Wan 2.2 steps must be positive.") + + +def main() -> None: + establish_process_group() + os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") + args = parse_args() + validate_args(args) + from huggingface_hub import snapshot_download + log("STAGE:resolve-cache") + try: + snapshot = snapshot_download(repo_id=args.model_repo, revision=args.model_revision, local_files_only=True) + except Exception as exc: + raise RuntimeError("The pinned Wan 2.2 snapshot is incomplete. Use Download or Repair in Video Gen.") from exc + import torch + from diffusers import AutoencoderKLWan, WanPipeline + from diffusers.utils import export_to_video + if not torch.cuda.is_available(): + raise RuntimeError("Wan 2.2 CUDA needs a visible NVIDIA device. Repair the runtime in Video Gen.") + emit_runtime_fingerprint("wan22_cuda", ["torch", "diffusers", "transformers", "accelerate", "huggingface-hub", "hf-xet"]) + log("STATUS:Loading Wan 2.2 TI2V 5B with component CPU offload") + log("STAGE:load-pipeline") + with heartbeat("wan22-cuda-load"): + vae = AutoencoderKLWan.from_pretrained(snapshot, subfolder="vae", torch_dtype=torch.float32, local_files_only=True) + pipe = WanPipeline.from_pretrained(snapshot, vae=vae, torch_dtype=torch.bfloat16, local_files_only=True) + # Each component fits independently on a 24 GB card (the largest is + # the ~20 GB transformer). Model-level offload moves each component + # once; sequential offload moved every layer every step and made a + # 3090 needlessly slow without lowering the peak that matters here. + pipe.enable_model_cpu_offload() + pipe.vae.enable_tiling() + generator = torch.Generator(device="cpu").manual_seed(args.seed) + log("STAGE:inference") + with heartbeat("wan22-cuda-inference"): + frames = pipe(prompt=args.prompt, negative_prompt=args.negative_prompt or None, height=args.height, width=args.width, num_frames=args.num_frames, num_inference_steps=args.steps, guidance_scale=args.guidance, generator=generator).frames[0] + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + log("STAGE:mux") + export_to_video(frames, str(output), fps=int(args.fps)) + if not output.is_file(): + raise RuntimeError(f"Wan 2.2 completed but did not write {output}.") + log(f"STATUS:Wan 2.2 saved {output.name} ({len(frames)} frames)") + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements-ltx25-cuda.txt b/scripts/requirements-ltx25-cuda.txt new file mode 100644 index 000000000..14b74a9be --- /dev/null +++ b/scripts/requirements-ltx25-cuda.txt @@ -0,0 +1,13 @@ +# LTX-2.5 CUDA runtime for ~/.portos/ltx-2.5-cuda/.venv. +# Torch, torchaudio and torchvision are installed separately as a matched stack +# from the host-appropriate CUDA index. + +ltx-core @ git+https://github.com/Lightricks/LTX-2.git@v1.2.0#subdirectory=packages/ltx-core +ltx-pipelines @ git+https://github.com/Lightricks/LTX-2.git@v1.2.0#subdirectory=packages/ltx-pipelines +transformers>=5.14.1,<5.15 +accelerate>=1.14.0 +huggingface_hub>=1.27.0 +imageio>=2.37.2 +imageio-ffmpeg>=0.6.0 +pillow>=12.3.0 +av>=18.1.0 diff --git a/scripts/requirements-wan22-cuda.txt b/scripts/requirements-wan22-cuda.txt new file mode 100644 index 000000000..00eb28a67 --- /dev/null +++ b/scripts/requirements-wan22-cuda.txt @@ -0,0 +1,11 @@ +diffusers==0.36.0 +transformers>=4.49,<5 +accelerate>=1.2,<2 +huggingface-hub>=0.27,<2 +hf-xet>=1.1,<2 +sentencepiece>=0.2,<1 +ftfy>=6.3,<7 +imageio>=2.36,<3 +imageio-ffmpeg>=0.5,<1 +av>=14,<17 +Pillow>=10,<13 diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index 0b468516b..4fa926702 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -15,6 +15,8 @@ # INSTALL_MINIMAX_H3 '1' to install the pinned MiniMax H3 MLX runtime at ~/.portos/minimax-h3-mlx (Apple Silicon). Weights remain a separate explicit Video Gen download. Default: 0. # INSTALL_MERERUN '1' to install the signed mere.run v0.47.0 runtime at ~/.portos/mere-run for MiniMax H3 Ref2VA image+audio generation (Apple Silicon). Weights remain a separate explicit Video Gen download. Default: 0. # INSTALL_MINIMAX_H3_CUDA '1' to install the MiniMax H3 CUDA runtime at ~/.portos/minimax-h3-cuda (Windows + NVIDIA), via diffusers' MiniMaxH3ModularPipeline. Weights remain a separate explicit Video Gen download (~144 GB). Default: 0. +# INSTALL_LTX25_CUDA '1' to install the official LTX-2.5 CUDA runtime at ~/.portos/ltx-2.5-cuda (Windows/Linux + NVIDIA). Weights remain a separate explicit Video Gen download. Default: 0. +# INSTALL_WAN22_CUDA '1' to install the official Wan 2.2 Diffusers runtime at ~/.portos/wan2.2-cuda (Windows/Linux + NVIDIA). Weights remain a separate explicit Video Gen download. Default: 0. # INSTALL_FLUX2 '1' to also bootstrap a separate venv at ~/.portos/venv-flux2 for FLUX.2-klein (default: 1 on macOS, 0 elsewhere) # INSTALL_MUSICGEN '1' to bootstrap a venv at ~/.portos/venv-musicgen + clone ml-explore/mlx-examples to ~/.portos/mlx-examples for local MusicGen (MLX) background-music generation (pipeline audio stage). Default: 0; opt in with INSTALL_MUSICGEN=1 (macOS / Apple Silicon only). # MLX_EXAMPLES_PIN commit SHA of ml-explore/mlx-examples to check out for MusicGen (default: main). @@ -81,7 +83,7 @@ venv_exists() { [[ -x "$1/bin/python3" || -x "$1/Scripts/python.exe" ]]; } # on a machine without Python. python_required() { local non_mere_requests - non_mere_requests="${INSTALL_MFLUX:-0}${INSTALL_VIDEO:-0}${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}${INSTALL_FLUX2:-0}" + non_mere_requests="${INSTALL_MFLUX:-0}${INSTALL_VIDEO:-0}${INSTALL_LTX2:-0}${INSTALL_LTX25:-0}${INSTALL_LTX25_CUDA:-0}${INSTALL_FASTVIDEO:-0}${INSTALL_WAN22:-0}${INSTALL_WAN22_CUDA:-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}${INSTALL_FLUX2:-0}" [[ "${INSTALL_MERERUN:-0}" != "1" || "$non_mere_requests" == *[!0]* ]] } @@ -120,7 +122,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_MINIMAX_H3:-0}${INSTALL_MERERUN:-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_LTX25_CUDA:-0}${INSTALL_FASTVIDEO:-0}${INSTALL_WAN22:-0}${INSTALL_WAN22_CUDA:-0}${INSTALL_MINIMAX_H3:-0}${INSTALL_MERERUN:-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 @@ -641,6 +643,69 @@ 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_LTX25_CUDA="${INSTALL_LTX25_CUDA:-0}" +if [[ "$INSTALL_LTX25_CUDA" == "1" ]]; then + if is_macos; then + echo "❌ LTX-2.5 CUDA needs an NVIDIA GPU. On Apple Silicon use the LTX-2.5 MLX runtime instead." >&2 + exit 1 + fi + LTX25_CUDA_DIR="${HOME}/.portos/ltx-2.5-cuda" + LTX25_CUDA_VENV="${LTX25_CUDA_DIR}/.venv" + LTX25_CUDA_REQS="${SCRIPT_DIR}/requirements-ltx25-cuda.txt" + LTX25_CUDA_TORCH_INDEX="${PORTOS_LTX25_CUDA_TORCH_INDEX:-https://download.pytorch.org/whl/cu128}" + mkdir -p "$LTX25_CUDA_DIR" + if ! venv_exists "$LTX25_CUDA_VENV"; then + echo "📦 Creating LTX-2.5 CUDA venv..." + "$PYTHON_BIN" -m venv "$LTX25_CUDA_VENV" + fi + LTX25_CUDA_PY="$(venv_python "$LTX25_CUDA_VENV")" + "$LTX25_CUDA_PY" -m pip install --disable-pip-version-check --upgrade pip wheel setuptools + if is_windows; then + echo "📦 Installing LTX-validated CUDA torch 2.10 stack from ${LTX25_CUDA_TORCH_INDEX}..." + "$LTX25_CUDA_PY" -m pip install --upgrade --index-url "$LTX25_CUDA_TORCH_INDEX" "torch==2.10.0" "torchaudio==2.10.0" "torchvision==0.25.0" + else + "$LTX25_CUDA_PY" -m pip install --upgrade torch torchaudio torchvision + fi + echo "📦 Installing official LTX-2.5 CUDA packages..." + "$LTX25_CUDA_PY" -m pip install --upgrade --progress-bar on -r "$LTX25_CUDA_REQS" + probe_or_fail \ + "LTX-2.5 CUDA installed but its runtime probe failed." \ + "Check the CUDA torch and LTX package errors above, then use Repair in Video Gen." \ + "$LTX25_CUDA_PY" -c "import torch; import ltx_core, ltx_pipelines; from ltx_pipelines.distilled import DistilledPipeline; assert torch.cuda.is_available(), 'no CUDA device'" + echo "✅ LTX-2.5 CUDA runtime ready: ${LTX25_CUDA_PY}" + echo " Weights remain uninstalled until you accept the LTX license and choose Download in Video Gen." +fi + +INSTALL_WAN22_CUDA="${INSTALL_WAN22_CUDA:-0}" +if [[ "$INSTALL_WAN22_CUDA" == "1" ]]; then + if is_macos; then + echo "❌ Wan 2.2 CUDA needs an NVIDIA GPU. Use the Wan 2.2 MLX runtime on Apple Silicon." >&2 + exit 1 + fi + WAN22_CUDA_DIR="${HOME}/.portos/wan2.2-cuda" + WAN22_CUDA_VENV="${WAN22_CUDA_DIR}/.venv" + WAN22_CUDA_REQS="${SCRIPT_DIR}/requirements-wan22-cuda.txt" + mkdir -p "$WAN22_CUDA_DIR" + if ! venv_exists "$WAN22_CUDA_VENV"; then + echo "📦 Creating Wan 2.2 CUDA venv..." + "$PYTHON_BIN" -m venv "$WAN22_CUDA_VENV" + fi + WAN22_CUDA_PY="$(venv_python "$WAN22_CUDA_VENV")" + "$WAN22_CUDA_PY" -m pip install --disable-pip-version-check --upgrade pip wheel setuptools + if is_windows; then + "$WAN22_CUDA_PY" -m pip install --upgrade --index-url "$TORCH_CUDA_INDEX" torch torchvision + else + "$WAN22_CUDA_PY" -m pip install --upgrade torch torchvision + fi + "$WAN22_CUDA_PY" -m pip install --upgrade --progress-bar on -r "$WAN22_CUDA_REQS" + probe_or_fail \ + "Wan 2.2 CUDA installed but its runtime probe failed." \ + "Check the CUDA torch errors above, then use Repair in Video Gen." \ + "$WAN22_CUDA_PY" -c "import torch, hf_xet; from diffusers import AutoencoderKLWan, WanPipeline; assert torch.cuda.is_available(), 'no CUDA device'" + echo "✅ Wan 2.2 CUDA runtime ready: ${WAN22_CUDA_PY}" + echo " Weights remain uninstalled until Download is chosen in Video Gen." +fi + INSTALL_MUSICGEN="${INSTALL_MUSICGEN:-0}" if [[ "$INSTALL_MUSICGEN" == "1" ]]; then # Local background-music generation for the pipeline audio stage (Phase @@ -1039,6 +1104,12 @@ fi if [[ "$INSTALL_FASTVIDEO" == "1" ]]; then echo " FastVideo MLX: ${HOME}/.portos/fastvideo/.venv/bin/python3 (FastVideo @ ${FASTVIDEO_PIN:0:12})" fi +if [[ "$INSTALL_LTX25_CUDA" == "1" ]]; then + echo " LTX-2.5 CUDA: ${HOME}/.portos/ltx-2.5-cuda/.venv (official streamed PyTorch runtime)" +fi +if [[ "$INSTALL_WAN22_CUDA" == "1" ]]; then + echo " Wan 2.2 CUDA: ${HOME}/.portos/wan2.2-cuda/.venv (official Diffusers runtime)" +fi if [[ "$INSTALL_WAN22" == "1" ]]; then echo " Wan 2.2: ${HOME}/.portos/mlx-gen/.venv/bin/python3 (MLX-Gen @ ${WAN22_PIN:0:12})" echo " Weights remain uninstalled until Download is chosen in Video Gen." @@ -1079,4 +1150,8 @@ if [[ "$INSTALL_FLUX2" == "1" ]]; then echo " then save the Hugging Face token in PortOS Media Generation Settings." fi echo "" -echo "Set this Python path in PortOS Settings → Image Gen → Local." +if [[ "$ANY_BYOV" == *[!0]* ]]; then + echo "PortOS auto-discovers the requested runtime; no Python path or other manual configuration is needed." +else + echo "Set this Python path in PortOS Settings → Image Gen → Local." +fi diff --git a/scripts/setup-image-video.test.js b/scripts/setup-image-video.test.js index 0ade24a24..fe1bbbd1e 100644 --- a/scripts/setup-image-video.test.js +++ b/scripts/setup-image-video.test.js @@ -15,13 +15,17 @@ const pythonRequiredHelper = source.match(/^python_required\(\) \{\n(?:.*\n)*?^\ const tempVenvs = []; const installFlags = [ 'INSTALL_MFLUX', 'INSTALL_VIDEO', 'INSTALL_LTX2', 'INSTALL_LTX25', - 'INSTALL_FASTVIDEO', 'INSTALL_WAN22', 'INSTALL_MINIMAX_H3', + 'INSTALL_LTX25_CUDA', 'INSTALL_FASTVIDEO', 'INSTALL_WAN22', 'INSTALL_WAN22_CUDA', 'INSTALL_MINIMAX_H3', 'INSTALL_MERERUN', 'INSTALL_MINIMAX_H3_CUDA', 'INSTALL_MUSICGEN', 'INSTALL_AUDIOLDM2', 'INSTALL_ACESTEP', 'INSTALL_ACESTEP15', 'INSTALL_MINIMAX_MUSIC3', 'INSTALL_MINIMAX_MUSIC3_MLX', 'INSTALL_MUSCRIPTOR', 'INSTALL_FLUX2', ]; +it('tells UI-driven BYOV installs that no manual Python configuration is needed', () => { + expect(source).toContain('PortOS auto-discovers the requested runtime; no Python path or other manual configuration is needed.'); +}); + afterEach(() => { while (tempVenvs.length) rmSync(tempVenvs.pop(), { recursive: true, force: true }); }); @@ -52,11 +56,14 @@ function venvExists(venv) { } function pythonRequired(env = {}) { - const installEnv = Object.fromEntries(installFlags.map((name) => [name, '0'])); + const installEnv = Object.fromEntries(installFlags.map((name) => [name, env[name] ?? '0'])); + const assignments = Object.entries(installEnv) + .map(([name, value]) => `${name}=${JSON.stringify(value)}`) + .join('\n'); const result = execFileSync( 'bash', - ['-c', `${pythonRequiredHelper}\npython_required && echo yes || echo no`], - { encoding: 'utf8', env: { ...process.env, ...installEnv, ...env } }, + ['-c', `${assignments}\n${pythonRequiredHelper}\npython_required && echo yes || echo no`], + { encoding: 'utf8', env: process.env }, ).trim(); return result === 'yes'; } @@ -93,10 +100,12 @@ describe('setup-image-video venv layout handling (issue #4200)', () => { expect(existsHelper).toBeTruthy(); }); - it('requires no Python only for an explicit mere.run-only install', () => { + it.skipIf(process.platform === 'win32')('requires no Python only for an explicit mere.run-only install', () => { expect(pythonRequiredHelper).toBeTruthy(); expect(pythonRequired({ INSTALL_MERERUN: '1' })).toBe(false); expect(pythonRequired({ INSTALL_MERERUN: '1', INSTALL_LTX25: '1' })).toBe(true); + expect(pythonRequired({ INSTALL_MERERUN: '1', INSTALL_LTX25_CUDA: '1' })).toBe(true); + expect(pythonRequired({ INSTALL_MERERUN: '1', INSTALL_WAN22_CUDA: '1' })).toBe(true); expect(pythonRequired({ INSTALL_MERERUN: '0' })).toBe(true); }); @@ -135,6 +144,8 @@ describe('setup-image-video venv layout handling (issue #4200)', () => { it.each([ ['MiniMax H3 CUDA', 'MINIMAX_H3_CUDA_VENV', 'MINIMAX_H3_CUDA_PY'], + ['LTX-2.5 CUDA', 'LTX25_CUDA_VENV', 'LTX25_CUDA_PY'], + ['Wan 2.2 CUDA', 'WAN22_CUDA_VENV', 'WAN22_CUDA_PY'], ['AudioLDM2', 'AUDIOLDM2_VENV', 'AUDIOLDM2_PY'], ['ACE-Step', 'ACESTEP_VENV', 'ACESTEP_PY'], ['MiniMax Music 3', 'MINIMAX_MUSIC3_VENV', 'MINIMAX_MUSIC3_PY'], @@ -146,6 +157,11 @@ describe('setup-image-video venv layout handling (issue #4200)', () => { expect(source).toContain(`${python}="$(venv_python "$${venv}")"`); }); + it('pins LTX-2.5 CUDA to Lightricks Desktop\'s validated Windows torch stack', () => { + expect(source).toContain('https://download.pytorch.org/whl/cu128'); + expect(source).toContain('"torch==2.10.0" "torchaudio==2.10.0" "torchvision==0.25.0"'); + }); + it('has no call site that hardcodes a venv interpreter path outside the shared helpers', () => { // A literal "$SOMETHING_VENV/bin/python3" assignment outside the helper // definitions means a call site bypassed venv_python() and reintroduced diff --git a/server/lib/mediaModels.js b/server/lib/mediaModels.js index 114102020..0a453dabb 100644 --- a/server/lib/mediaModels.js +++ b/server/lib/mediaModels.js @@ -208,6 +208,15 @@ const MINIMAX_H3_CUDA_REPO_FILES = Object.freeze([ 'audio_scheduler/scheduler_config.json', ]); +const LTX25_CUDA_REPO_FILES = Object.freeze([ + 'diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors', + 'text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors', + 'vae/ltx-2.5-video-vae-bf16.safetensors', + 'vae/ltx-2.5-audio-vae-bf16.safetensors', + 'model_patches/ltx-2.5-duration-head-bf16.safetensors', + 'latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors', +]); + // The diffusers integration's duration window, which is NARROWER than the MLX // port's at both ends: frames are snapped up to the next 17n+5 and the RESULTING // duration must land in 5-15 s, so 107 (4.46 s) is under the floor and 362 @@ -608,6 +617,46 @@ const DEFAULT_REGISTRY = { ]))))), cuda: applyMiniMaxH3MemoryProfiles(applyVideoDraftDecoders(applyVideoSpeedProfiles(applyVideoFinishProfiles(applyVideoDisclosures([ { id: 'ltx_video', name: 'LTX-Video 0.9.5 — T2V + I2V (~9.5 GB, auto-downloads)', runtime: 'cuda_video', steps: 25, guidance: 3.0 }, + { + id: 'ltx25_cuda_distilled', + name: 'LTX-2.5 CUDA Distilled (joint video + audio, ~72 GB download, streamed)', + repo: 'Lightricks/LTX-2.5', + revision: 'bf86adedf518142442575d1ce2e767b7d01c8c76', + repoFiles: [...LTX25_CUDA_REPO_FILES], + runtime: 'ltx25_cuda', + supportedModes: ['text', 'image'], + defaultFrames: 121, + resolutionStep: 64, + fpsOptions: [24], + steps: 8, + guidance: 1.0, + samplerLocked: true, + samplerNote: 'LTX-2.5 Distilled uses the official fixed 8-step, CFG-free schedule.', + supportsNegativePrompt: false, + supportsTiling: false, + supportsDisableAudio: true, + requiresHfToken: true, + hardwareRequirements: { minMemoryGb: 64, minVramGb: 16, minCudaComputeCapability: 8 }, + }, + { + id: 'wan22_cuda_ti2v_5b', + name: 'Wan 2.2 TI2V 5B CUDA (high quality, ~34 GB download, text-to-video)', + repo: 'Wan-AI/Wan2.2-TI2V-5B-Diffusers', + revision: 'b8fff7315c768468a5333511427288870b2e9635', + runtime: 'wan22_cuda', + supportedModes: ['text'], + defaultWidth: 1280, + defaultHeight: 704, + resolutionStep: 16, + defaultFrames: 121, + frameStride: 4, + fpsOptions: [24], + steps: 50, + guidance: 5, + supportsNegativePrompt: true, + supportsTiling: false, + hardwareRequirements: { minMemoryGb: 32, minVramGb: 24, minCudaComputeCapability: 8 }, + }, // MiniMax H3 on NVIDIA, through diffusers' MiniMaxH3ModularPipeline — // the same joint video+audio model the MLX list runs on Apple Silicon, so it // shares H3's canvas grid, its fixed 24 fps, its locked CFG-distilled @@ -1030,6 +1079,18 @@ const upgradeLegacyCudaLtxRuntime = (list) => { )); }; +export const upgradeLtx25CudaMemoryFloor = (list) => { + if (!Array.isArray(list)) return list; + return list.map((entry) => ( + isPlainObject(entry) + && entry.id === 'ltx25_cuda_distilled' + && entry.repo === 'Lightricks/LTX-2.5' + && entry.hardwareRequirements?.minMemoryGb === 32 + ? { ...entry, hardwareRequirements: { ...entry.hardwareRequirements, minMemoryGb: 64 } } + : entry + )); +}; + // 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 @@ -1191,7 +1252,9 @@ const normalizeRegistry = (parsed) => { const normalized = backfillRuntime(upgradeLtx25AudioControls( upgradeMiniMaxH3OutputControls(dropRetiredEntries(entries)), )); - const upgraded = upgradeLegacyCudaLtx ? upgradeLegacyCudaLtxRuntime(normalized) : normalized; + const upgraded = upgradeLegacyCudaLtx + ? upgradeLtx25CudaMemoryFloor(upgradeLegacyCudaLtxRuntime(normalized)) + : normalized; const decorated = sanitizeFinishProfiles(applyVideoFinishProfiles(applyVideoDisclosures(upgraded))); // applyVideoSpeedProfiles is the load-time twin of migration 295, and // sanitizeSpeedProfiles is its sibling of sanitizeFinishProfiles: a diff --git a/server/lib/mediaModels.test.js b/server/lib/mediaModels.test.js index c5debf99d..1b300a9ee 100644 --- a/server/lib/mediaModels.test.js +++ b/server/lib/mediaModels.test.js @@ -38,6 +38,24 @@ describe('data.reference seed file', () => { }); }); +describe('LTX-2.5 CUDA compatibility upgrade', () => { + it('raises only the untouched official 32 GB row to the validated 64 GB floor', async () => { + const { upgradeLtx25CudaMemoryFloor } = await import('./mediaModels.js'); + const official = { + id: 'ltx25_cuda_distilled', + repo: 'Lightricks/LTX-2.5', + hardwareRequirements: { minMemoryGb: 32, minVramGb: 16 }, + }; + const fork = { ...official, repo: 'example/LTX-fork' }; + const overridden = { ...official, hardwareRequirements: { minMemoryGb: 48, minVramGb: 16 } }; + expect(upgradeLtx25CudaMemoryFloor([official, fork, overridden])).toEqual([ + { ...official, hardwareRequirements: { minMemoryGb: 64, minVramGb: 16 } }, + fork, + overridden, + ]); + }); +}); + describe('mediaModels registry', () => { it('seeds the registry file on first load', async () => { expect(existsSync(registryFile)).toBe(false); @@ -74,6 +92,57 @@ describe('mediaModels registry', () => { expect(ltx25.disclosure.estimatedDownloadGb).toBe(67.7); }); + it('ships official LTX-2.5 CUDA as a pinned, streamed 3090-class profile', async () => { + const { loadMediaModels } = await import('./mediaModels.js'); + const ltx25 = loadMediaModels().video.cuda.find((model) => model.id === 'ltx25_cuda_distilled'); + expect(ltx25).toMatchObject({ + runtime: 'ltx25_cuda', + repo: 'Lightricks/LTX-2.5', + revision: 'bf86adedf518142442575d1ce2e767b7d01c8c76', + supportedModes: ['text', 'image'], + defaultFrames: 121, + resolutionStep: 64, + fpsOptions: [24], + steps: 8, + guidance: 1, + samplerLocked: true, + supportsNegativePrompt: false, + supportsDisableAudio: true, + requiresHfToken: true, + hardwareRequirements: { + minMemoryGb: 64, + minVramGb: 16, + minCudaComputeCapability: 8, + }, + }); + const wan = loadMediaModels().video.cuda.find((model) => model.id === 'wan22_cuda_ti2v_5b'); + expect(wan).toMatchObject({ + repo: 'Wan-AI/Wan2.2-TI2V-5B-Diffusers', + revision: 'b8fff7315c768468a5333511427288870b2e9635', + runtime: 'wan22_cuda', + supportedModes: ['text'], + defaultWidth: 1280, + defaultHeight: 704, + defaultFrames: 121, + frameStride: 4, + hardwareRequirements: { + minMemoryGb: 32, + minVramGb: 24, + minCudaComputeCapability: 8, + }, + }); + expect(ltx25.repoFiles).toContain( + 'diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors', + ); + expect(ltx25.repoFiles).toContain( + 'text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors', + ); + expect(ltx25.disclosure).toMatchObject({ + estimatedDownloadGb: 72.1, + reviewedAt: '2026-08-30', + }); + }); + it('ships MiniMax H3 as a pinned, keyframe-capable 128 GB BYOV profile', async () => { const { loadMediaModels } = await import('./mediaModels.js'); // H3 is an Apple-silicon MLX runtime, so inspect the shipped macOS catalog diff --git a/server/lib/videoDisclosure.js b/server/lib/videoDisclosure.js index ccd933786..e38e1b64b 100644 --- a/server/lib/videoDisclosure.js +++ b/server/lib/videoDisclosure.js @@ -63,6 +63,8 @@ const RUNTIME_LICENSE = { mlx_video: { name: 'MIT', url: 'https://pypi.org/project/mlx-video-with-audio/' }, ltx2: { name: 'MIT', url: 'https://github.com/dgrauet/ltx-2-mlx/blob/main/LICENSE' }, ltx25: { name: 'MIT', url: 'https://github.com/MrMoferFRAN/ltx-2-mlx/blob/57952288076766abe27dda3a774b2c24f7346977/LICENSE' }, + ltx25_cuda: { name: 'Apache-2.0', url: 'https://github.com/Lightricks/LTX-2/blob/v1.2.0/LICENSE' }, + wan22_cuda: { name: 'Apache-2.0', url: 'https://github.com/huggingface/diffusers/blob/main/LICENSE' }, wan22: { name: 'MIT', url: 'https://github.com/lpalbou/mlx-gen/blob/main/LICENSE' }, minimax_h3: { name: 'Apache-2.0', @@ -232,6 +234,26 @@ export const VIDEO_MODEL_DISCLOSURES = Object.freeze({ reviewedAt: VIDEO_DISCLOSURE_REVIEWED_AT, }, }, + ltx25_cuda_distilled: { + shippedRepo: 'Lightricks/LTX-2.5', + disclosure: { + modelCardUrl: hfModelCard('Lightricks/LTX-2.5'), + weightsLicense: LTX_2X_WEIGHTS, + runtimeLicense: RUNTIME_LICENSE.ltx25_cuda, + estimatedDownloadGb: 72.1, + reviewedAt: VIDEO_DISCLOSURE_REVIEWED_AT, + }, + }, + wan22_cuda_ti2v_5b: { + shippedRepo: 'Wan-AI/Wan2.2-TI2V-5B-Diffusers', + disclosure: { + modelCardUrl: hfModelCard('Wan-AI/Wan2.2-TI2V-5B-Diffusers'), + weightsLicense: APACHE_2, + runtimeLicense: RUNTIME_LICENSE.wan22_cuda, + estimatedDownloadGb: 34.2, + reviewedAt: VIDEO_DISCLOSURE_REVIEWED_AT, + }, + }, wan22_ti2v_5b: { shippedRepo: 'AbstractFramework/wan2.2-ti2v-5b-diffusers-8bit', disclosure: { diff --git a/server/lib/videoModeProfiles.js b/server/lib/videoModeProfiles.js index 945c6ddc6..fa8490608 100644 --- a/server/lib/videoModeProfiles.js +++ b/server/lib/videoModeProfiles.js @@ -62,7 +62,9 @@ export const VIDEO_RUNTIME_MODES = Object.freeze({ mlx_video: Object.freeze(['text', 'image', 'fflf', 'extend']), ltx2: Object.freeze(['text', 'image', 'fflf', 'extend', 'a2v']), ltx25: Object.freeze(['text', 'image', 'fflf', 'extend', 'a2v']), + ltx25_cuda: Object.freeze(['text', 'image']), wan22: Object.freeze(['text', 'image']), + wan22_cuda: Object.freeze(['text']), fastvideo: Object.freeze(['text', 'image']), minimax_h3: MINIMAX_H3_MODE_SET, minimax_h3_cuda: MINIMAX_H3_MODE_SET, diff --git a/server/services/lmStudioManager.js b/server/services/lmStudioManager.js index bd628eefe..757da0732 100644 --- a/server/services/lmStudioManager.js +++ b/server/services/lmStudioManager.js @@ -26,6 +26,7 @@ const AVAILABILITY_PROBE_TIMEOUT_MS = 5_000 // `lms server start|stop` only asks the already-installed app to flip its // listener — it never downloads anything, so a minute is generous. const LMS_CONTROL_TIMEOUT_MS = 60_000 +const LMS_UNLOAD_TIMEOUT_MS = 60_000 // `lms load` reads a multi-gigabyte GGUF off disk and allocates its KV cache; // on a cold page cache that is minutes, not seconds. const LMS_LOAD_TIMEOUT_MS = 300_000 @@ -412,12 +413,22 @@ async function unloadModel(modelId) { return { success: false, error: 'LM Studio not available' } } - const response = await lmStudioRequest('/api/v1/models/unload', { + let response = await lmStudioRequest('/api/v1/models/unload', { method: 'POST', body: JSON.stringify({ model: modelId }), timeout: 15000 }).catch(err => ({ _err: err.message })) + // LM Studio releases have changed the native unload payload contract. A + // 400/404/405 means the server is reachable but this endpoint shape is not; + // fall back to the app's own non-interactive CLI, which accepts the stable + // model identifier returned by /api/v0/models. Do not bypass a 5xx "busy" + // response: that is a real runtime refusal, not API drift. + if (response._err && /\b(?:400|404|405)\b/.test(response._err)) { + const cli = await runLms(['unload', modelId], { timeoutMs: LMS_UNLOAD_TIMEOUT_MS }) + response = cli.success ? {} : { _err: `${response._err}; ${cli.error}` } + } + if (response._err) { console.error(`⚠️ Failed to unload model ${modelId}: ${response._err}`) return { success: false, error: response._err, modelId } diff --git a/server/services/lmStudioManager.test.js b/server/services/lmStudioManager.test.js index 0d9dea879..622ae02b8 100644 --- a/server/services/lmStudioManager.test.js +++ b/server/services/lmStudioManager.test.js @@ -136,6 +136,36 @@ describe('lmStudioManager residency status', () => { await expect(getLoadedModels(true)).resolves.toEqual([]); expect(getLastLoadedModelsError()).toMatch(/offline|unavailable/i); }); + + it('falls back to the LM Studio CLI when the native unload contract returns 400', async () => { + vi.stubGlobal('fetch', vi.fn(async (url) => { + if (String(url).includes('/models/unload')) { + return { + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({}), + text: async () => 'Bad Request', + }; + } + const data = [{ id: 'example/model', state: 'loaded', type: 'llm' }]; + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ data }), + text: async () => JSON.stringify({ data }), + }; + })); + const { unloadModel } = await import('./lmStudioManager.js'); + + await expect(unloadModel('example/model')).resolves.toMatchObject({ success: true }); + expect(lmsSpawn).toHaveBeenCalledWith( + '/usr/local/bin/lms', + ['unload', 'example/model'], + expect.objectContaining({ shell: false }), + ); + }); }); describe('lmStudioManager deleteModel', () => { diff --git a/server/services/videoGen/generateVideo.js b/server/services/videoGen/generateVideo.js index 5f3dc120e..51e4760f0 100644 --- a/server/services/videoGen/generateVideo.js +++ b/server/services/videoGen/generateVideo.js @@ -204,7 +204,7 @@ export async function generateVideo({ pythonPath, prompt, negativePrompt = '', m numFrames = numFrames ?? model.defaultFrames ?? DEFAULT_NUM_FRAMES; let wanModelPath = null; const wanRequiredWeights = []; - if (model.runtime === 'wan22') { + if (model.runtime === 'wan22' || model.runtime === 'wan22_cuda') { const frameStride = Number(model.frameStride); if (Number.isFinite(frameStride) && frameStride > 0 && (Number(numFrames) - 1) % frameStride !== 0) { throw new ServerError( diff --git a/server/services/videoGen/local.test.js b/server/services/videoGen/local.test.js index 3c81fd75a..592161b0b 100644 --- a/server/services/videoGen/local.test.js +++ b/server/services/videoGen/local.test.js @@ -149,6 +149,18 @@ vi.mock('../../lib/mediaModels.js', async () => { termsGate: { id: 'minimax-h3-community-license-2026-08-02' }, memoryProfiles: [{ id: 'int8-lean', name: 'int8, leaf-level', minMemoryGb: 1, minVramGb: 12, unified: false }], }, + { + id: 'ltx25_cuda_distilled', name: 'LTX-2.5 CUDA Distilled', runtime: 'ltx25_cuda', + repo: 'Lightricks/LTX-2.5', + revision: 'bf86adedf518142442575d1ce2e767b7d01c8c76', + repoFiles: [ + 'diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors', + 'text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors', + ], + supportedModes: ['text', 'image'], defaultFrames: 121, fpsOptions: [24], + steps: 8, guidance: 1, samplerLocked: true, + supportsNegativePrompt: false, supportsDisableAudio: true, + }, { id: 'wan22_ti2v_5b', name: 'Wan TI2V', runtime: 'wan22', repo: 'AbstractFramework/wan2.2-ti2v-5b-diffusers-8bit', @@ -156,6 +168,14 @@ vi.mock('../../lib/mediaModels.js', async () => { supportedModes: ['text', 'image'], frameStride: 4, steps: 25, guidance: 5, guidance2: null, flowShift: 3, solver: 'unipc', }, + { + id: 'wan22_cuda_ti2v_5b', name: 'Wan TI2V CUDA', runtime: 'wan22_cuda', + repo: 'Wan-AI/Wan2.2-TI2V-5B-Diffusers', + revision: 'b8fff7315c768468a5333511427288870b2e9635', + supportedModes: ['text'], frameStride: 4, defaultFrames: 81, + defaultWidth: 1280, defaultHeight: 704, resolutionStep: 16, + defaultFrames: 121, steps: 50, guidance: 5, + }, { id: 'wan22_t2v_a14b_lightning', name: 'Wan T2V Lightning', runtime: 'wan22', repo: 'AbstractFramework/wan2.2-t2v-a14b-diffusers-8bit', @@ -4469,6 +4489,123 @@ describe('generateVideo — MiniMax H3 CUDA contract', () => { }); }); +describe('generateVideo — LTX-2.5 CUDA contract', () => { + const ltx25Call = (spawnMock) => spawnMock.mock.calls.find(([, args]) => ( + Array.isArray(args) && args.some((arg) => basename(String(arg)) === 'generate_ltx25_cuda.py') + )); + + it('dispatches a cache-only first-frame render through the dedicated streamed runtime', async () => { + const { spawnDetached } = await import('../../lib/detachedSpawn.js'); + const spawnMock = vi.mocked(spawnDetached); + spawnMock.mockClear(); + + await generateVideo({ + jobId: 'ltx25-cuda-args', + modelId: 'ltx25_cuda_distilled', + prompt: 'a fox watches the rain', + width: 768, height: 512, numFrames: 121, fps: 24, + steps: 99, guidanceScale: 12, mode: 'image', + sourceImagePath: '/mock/source.png', imageStrength: 0.7, + disableAudio: true, + }); + + const call = ltx25Call(spawnMock); + expect(call).toBeDefined(); + const [bin, args, options] = call; + expect(String(bin)).toContain(join('.portos', 'ltx-2.5-cuda')); + expect(args[args.indexOf('--model-repo') + 1]).toBe('Lightricks/LTX-2.5'); + expect(args[args.indexOf('--model-revision') + 1]) + .toBe('bf86adedf518142442575d1ce2e767b7d01c8c76'); + expect(args[args.indexOf('--steps') + 1]).toBe('8'); + expect(args).not.toContain('--guidance'); + expect(basename(args[args.indexOf('--image') + 1])) + .toBe('resized-src-ltx25-cuda-args.png'); + expect(args[args.indexOf('--image-strength') + 1]).toBe('0.7'); + expect(args).toContain('--disable-audio'); + expect(args.flatMap((arg, i) => (arg === '--repo-file' ? [args[i + 1]] : []))).toEqual([ + 'diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors', + 'text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors', + ]); + expect(options.env).toMatchObject({ + HF_HUB_DISABLE_IMPLICIT_TOKEN: '1', + HF_HUB_OFFLINE: '1', + TRANSFORMERS_OFFLINE: '1', + }); + expect(options.killProcessGroup).toBe(true); + }); + + it('rejects image mode without a source at the render boundary', async () => { + await expect(generateVideo({ + jobId: 'ltx25-cuda-missing-image', + modelId: 'ltx25_cuda_distilled', + prompt: 'a fox watches the rain', + mode: 'image', + })).rejects.toMatchObject({ code: 'LTX25_CUDA_I2V_REQUIRES_IMAGE' }); + }); + + it('promotes a staged upload before enforcing the image contract', async () => { + const { spawnDetached } = await import('../../lib/detachedSpawn.js'); + const spawnMock = vi.mocked(spawnDetached); + spawnMock.mockClear(); + + await generateVideo({ + jobId: 'ltx25-cuda-staged-image', + modelId: 'ltx25_cuda_distilled', + prompt: 'a fox watches the rain', + mode: 'image', uploadedTempPath: '/mock/upload.png', + }); + + const [, args] = ltx25Call(spawnMock); + expect(basename(args[args.indexOf('--image') + 1])) + .toBe('resized-src-ltx25-cuda-staged-image.png'); + }); +}); + +describe('generateVideo — Wan 2.2 CUDA contract', () => { + it('dispatches a pinned, cache-only text render through the dedicated CUDA runtime', async () => { + const { spawnDetached } = await import('../../lib/detachedSpawn.js'); + const spawnMock = vi.mocked(spawnDetached); + spawnMock.mockClear(); + + await generateVideo({ + jobId: 'wan22-cuda-args', + modelId: 'wan22_cuda_ti2v_5b', + prompt: 'a fox watches the rain', + negativePrompt: 'text, watermark', + width: 832, height: 480, numFrames: 81, fps: 24, + steps: 12, guidanceScale: 4.5, mode: 'text', + }); + + const call = spawnMock.mock.calls.find(([, args]) => ( + Array.isArray(args) && args.some((arg) => basename(String(arg)) === 'generate_wan22_cuda.py') + )); + expect(call).toBeDefined(); + const [bin, args, options] = call; + expect(String(bin)).toContain(join('.portos', 'wan2.2-cuda')); + expect(args[args.indexOf('--model-repo') + 1]).toBe('Wan-AI/Wan2.2-TI2V-5B-Diffusers'); + expect(args[args.indexOf('--model-revision') + 1]) + .toBe('b8fff7315c768468a5333511427288870b2e9635'); + expect(args[args.indexOf('--steps') + 1]).toBe('12'); + expect(args[args.indexOf('--guidance') + 1]).toBe('4.5'); + expect(args[args.indexOf('--negative-prompt') + 1]).toBe('text, watermark'); + expect(options.env).toMatchObject({ + HF_HUB_DISABLE_IMPLICIT_TOKEN: '1', + HF_HUB_OFFLINE: '1', + TRANSFORMERS_OFFLINE: '1', + }); + expect(options.killProcessGroup).toBe(true); + }); + + it('rejects image mode even when a source is supplied', async () => { + await expect(generateVideo({ + jobId: 'wan22-cuda-image', + modelId: 'wan22_cuda_ti2v_5b', + prompt: 'a fox watches the rain', + mode: 'image', sourceImagePath: '/mock/source.png', + })).rejects.toMatchObject({ code: 'WAN22_MODE_UNSUPPORTED' }); + }); +}); + // ── one-shot prompt-encode relaunch after a Metal watchdog abort (#4589) ───── // A real Metal abort can't be produced here, so the child is driven directly: // the marker lines and the abort banner are pushed onto its stderr, then it is diff --git a/server/services/videoGen/modeContract.js b/server/services/videoGen/modeContract.js index 04898d44f..ed5c40d52 100644 --- a/server/services/videoGen/modeContract.js +++ b/server/services/videoGen/modeContract.js @@ -92,6 +92,42 @@ const MINIMAX_H3_REF2VA_CONTRACT = Object.freeze({ }), }); +const WAN22_CONTRACT = Object.freeze({ + codePrefix: 'WAN22', + chainCode: 'WAN22_CHAIN_REQUIRES_IMAGE_MODE', + // No ceiling: MLX-Gen's Wan CLI takes whatever the profile declares, and + // resolveVideoSupportedModes narrows an entry that declares nothing to the + // wan22 row (text + image) rather than leaving it unconstrained. + modeCeiling: null, + // The wan22 lane rejects multi-keyframe / extend / audio / IC inputs by + // runtime elsewhere, so folding them in here would double-report. + extraConditioningUnsupported: false, + messages: { + modeUnsupported: ({ model, requestedMode }) => `${model.name} does not support ${requestedMode}-to-video. Choose a compatible Wan model.`, + textSourceConflict: () => 'Wan 2.2 text-to-video cannot consume a source image — switch to image mode or remove the source.', + // Two phrasings, one rule: before staging, the caller can still supply an + // upload; after resolution, the gallery pick they *did* supply didn't + // resolve, and "upload one" would be misleading advice. + imageRequiresFirst: ({ sourceResolved }) => (sourceResolved + ? 'Wan 2.2 image-to-video requires a resolvable source image — choose an existing gallery image or upload one.' + : 'Wan 2.2 image-to-video requires a source image — upload one before running this model.'), + }, +}); + +// The CUDA helper currently exposes text generation only. Keep the existing +// Wan error contract, but cap hand-edited or peer-synced registry entries at +// the modes the helper can actually represent on its command line. +const WAN22_CUDA_CONTRACT = Object.freeze({ + ...WAN22_CONTRACT, + modeCeiling: VIDEO_RUNTIME_MODES.wan22_cuda, +}); + +const LTX25_CUDA_CONTRACT = Object.freeze({ + codePrefix: 'LTX25_CUDA', + modeCeiling: VIDEO_RUNTIME_MODES.ltx25_cuda, + extraConditioningUnsupported: false, +}); + /** * The per-runtime rows. `modeCeiling` is the set of modes the *helper* has * arguments for — a `null` ceiling means the registry entry's `supportedModes` @@ -101,27 +137,9 @@ const MINIMAX_H3_REF2VA_CONTRACT = Object.freeze({ * them. */ const VIDEO_MODE_CONTRACTS = Object.freeze({ - wan22: { - codePrefix: 'WAN22', - chainCode: 'WAN22_CHAIN_REQUIRES_IMAGE_MODE', - // No ceiling: MLX-Gen's Wan CLI takes whatever the profile declares, and - // resolveVideoSupportedModes narrows an entry that declares nothing to the - // wan22 row (text + image) rather than leaving it unconstrained. - modeCeiling: null, - // The wan22 lane rejects multi-keyframe / extend / audio / IC inputs by - // runtime elsewhere, so folding them in here would double-report. - extraConditioningUnsupported: false, - messages: { - modeUnsupported: ({ model, requestedMode }) => `${model.name} does not support ${requestedMode}-to-video. Choose a compatible Wan model.`, - textSourceConflict: () => 'Wan 2.2 text-to-video cannot consume a source image — switch to image mode or remove the source.', - // Two phrasings, one rule: before staging, the caller can still supply an - // upload; after resolution, the gallery pick they *did* supply didn't - // resolve, and "upload one" would be misleading advice. - imageRequiresFirst: ({ sourceResolved }) => (sourceResolved - ? 'Wan 2.2 image-to-video requires a resolvable source image — choose an existing gallery image or upload one.' - : 'Wan 2.2 image-to-video requires a source image — upload one before running this model.'), - }, - }, + wan22: WAN22_CONTRACT, + wan22_cuda: WAN22_CUDA_CONTRACT, + ltx25_cuda: LTX25_CUDA_CONTRACT, fastvideo: { codePrefix: 'FASTVIDEO', chainCode: 'FASTVIDEO_CHAIN_REQUIRES_IMAGE_MODE', diff --git a/server/services/videoGen/modeContract.test.js b/server/services/videoGen/modeContract.test.js index ccd9f7fb1..80ebdefd6 100644 --- a/server/services/videoGen/modeContract.test.js +++ b/server/services/videoGen/modeContract.test.js @@ -8,13 +8,15 @@ import { // Fake registry entries — never a real install's media-models.json. const wan = (supportedModes) => ({ runtime: 'wan22', name: 'Example Wan Profile', supportedModes }); +const wanCuda = (supportedModes) => ({ runtime: 'wan22_cuda', name: 'Example Wan CUDA Profile', supportedModes }); +const ltx25Cuda = (supportedModes) => ({ runtime: 'ltx25_cuda', name: 'Example LTX-2.5 CUDA Profile', supportedModes }); const fv = (supportedModes) => ({ runtime: 'fastvideo', name: 'Example FastVideo Model', supportedModes }); const h3 = (supportedModes) => ({ runtime: 'minimax_h3', name: 'Example H3', supportedModes }); const ref2va = (supportedModes = ['a2v']) => ({ runtime: 'minimax_h3_ref2va', name: 'Example H3 Ref2VA', supportedModes }); describe('videoModeContractError — shared gate', () => { it('gates exactly the runtimes that declare a contract row', () => { - expect([...VIDEO_MODE_GATED_RUNTIMES].sort()).toEqual(['fastvideo', 'minimax_h3', 'minimax_h3_cuda', 'minimax_h3_ref2va', 'wan22']); + expect([...VIDEO_MODE_GATED_RUNTIMES].sort()).toEqual(['fastvideo', 'ltx25_cuda', 'minimax_h3', 'minimax_h3_cuda', 'minimax_h3_ref2va', 'wan22', 'wan22_cuda']); }); it.each(['ltx2', 'mlx_video', undefined])('leaves the %s runtime ungated', (runtime) => { @@ -35,6 +37,22 @@ describe('videoModeContractError — shared gate', () => { }); }); +describe('videoModeContractError — CUDA runtime ceilings', () => { + it('enforces LTX-2.5 CUDA image/source pairing', () => { + expect(videoModeContractError({ model: ltx25Cuda(['text', 'image']), mode: 'image' })) + .toMatchObject({ code: 'LTX25_CUDA_I2V_REQUIRES_IMAGE' }); + expect(videoModeContractError({ + model: ltx25Cuda(['text', 'image']), mode: 'text', hasFirstImage: true, + })).toMatchObject({ code: 'LTX25_CUDA_TEXT_MODE_SOURCE_CONFLICT' }); + }); + + it('caps a hand-widened Wan CUDA entry at text mode', () => { + expect(videoModeContractError({ + model: wanCuda(['text', 'image']), mode: 'image', hasFirstImage: true, + })).toMatchObject({ code: 'WAN22_MODE_UNSUPPORTED' }); + }); +}); + describe('videoModeContractError — minimax_h3_ref2va', () => { it('requires both a source image and an audio file', () => { expect(videoModeContractError({ model: ref2va(), mode: 'a2v', audioFile: '/mock/audio.wav' })) diff --git a/server/services/videoGen/prepareParams.js b/server/services/videoGen/prepareParams.js index 8f157782f..7a8c3fcb7 100644 --- a/server/services/videoGen/prepareParams.js +++ b/server/services/videoGen/prepareParams.js @@ -170,7 +170,7 @@ export async function validateVideoRetryParams(params = {}) { }); if (controlError) throw controlError; } - if (model.runtime === 'wan22') { + if (model.runtime === 'wan22' || model.runtime === 'wan22_cuda') { const frameStride = Number(model.frameStride); if (Number.isFinite(frameStride) && frameStride > 0 && (Number(numFrames) - 1) % frameStride !== 0) { throw new ServerError( @@ -638,7 +638,7 @@ async function resolvePreparedParams({ // request schema can express (the mode side is the shared gate above). Mirror // the worker's frame-grid guard here so a direct API caller cannot persist a // job that is already known to fail. - if (effectiveModel?.runtime === 'wan22') { + if (effectiveModel?.runtime === 'wan22' || effectiveModel?.runtime === 'wan22_cuda') { const numFrames = body.numFrames != null ? Number(body.numFrames) : DEFAULT_NUM_FRAMES; const frameStride = Number(effectiveModel.frameStride); if (Number.isFinite(frameStride) && frameStride > 0 && (numFrames - 1) % frameStride !== 0) { diff --git a/server/services/videoGen/renderArgs.js b/server/services/videoGen/renderArgs.js index a4e079f2f..b413ddae5 100644 --- a/server/services/videoGen/renderArgs.js +++ b/server/services/videoGen/renderArgs.js @@ -50,6 +50,10 @@ import { FASTVIDEO_VENV_PYTHON, FASTVIDEO_HELPER_SCRIPT, FASTVIDEO_REPO_DIR, + LTX25_CUDA_VENV_PYTHON, + LTX25_CUDA_HELPER_SCRIPT, + WAN22_CUDA_VENV_PYTHON, + WAN22_CUDA_HELPER_SCRIPT, BYOV_RUNTIME_INFO, videoLoraUnsupportedError, routesToWindowsHelper, @@ -607,6 +611,33 @@ const buildWan22Args = ({ model, wanModelPath, wanRequiredWeights, prompt, negat return { bin: WAN22_VENV_PYTHON, args }; }; +const buildWan22CudaArgs = ({ model, wanModelPath, prompt, negativePrompt, width, height, numFrames, fps, steps, guidance, seed, sourceImagePath, mode, outputPath }) => { + assertByovRuntimeInstalled('wan22_cuda'); + assertRenderModeContract({ model, mode, sourceImagePath }); + if (!model?.repo || !model?.revision || !wanModelPath) { + throw new ServerError( + `Wan 2.2 CUDA model "${model?.id || 'unknown'}" is missing its pinned snapshot.`, + { status: 500, code: 'VIDEO_MODEL_MISCONFIGURED' }, + ); + } + const args = [ + WAN22_CUDA_HELPER_SCRIPT, + '--model-repo', model.repo, + '--model-revision', model.revision, + '--prompt', prompt, + '--width', String(width), + '--height', String(height), + '--num-frames', String(numFrames), + '--fps', String(fps), + '--steps', String(steps), + '--guidance', String(guidance ?? 5), + '--seed', String(seed), + '--output', outputPath, + ]; + if (negativePrompt) args.push('--negative-prompt', negativePrompt); + return { bin: WAN22_CUDA_VENV_PYTHON, args }; +}; + // Everything every H3 builder must clear before it starts assembling argv: // the venv is installed, the mode/source combination is legal, H3's fixed // controls were not overridden, and the entry carries its pin. The two lanes @@ -836,6 +867,49 @@ const buildMiniMaxH3CudaArgs = ({ model, prompt, negativePrompt, width, height, return { bin: MINIMAX_H3_CUDA_VENV_PYTHON, args }; }; +const buildLtx25CudaArgs = ({ model, prompt, negativePrompt, width, height, numFrames, fps, steps, seed, sourceImagePath, mode, imageStrength, disableAudio, outputPath }) => { + assertByovRuntimeInstalled('ltx25_cuda'); + assertRenderModeContract({ model, mode, sourceImagePath }); + if (!model?.repo || !model?.revision) { + throw new ServerError( + `LTX-2.5 model "${model?.id || 'unknown'}" is missing its pinned repo or revision.`, + { status: 500, code: 'VIDEO_MODEL_MISCONFIGURED' }, + ); + } + const files = Array.isArray(model.repoFiles) + ? model.repoFiles.filter((file) => typeof file === 'string' && file) + : []; + if (files.length === 0) { + throw new ServerError( + `LTX-2.5 model "${model.id}" is missing its split checkpoint file list.`, + { status: 500, code: 'VIDEO_MODEL_MISCONFIGURED' }, + ); + } + if (negativePrompt) { + throw new ServerError( + 'The distilled LTX-2.5 CUDA pipeline does not consume a negative prompt.', + { status: 400, code: 'VIDEO_NEGATIVE_PROMPT_UNSUPPORTED' }, + ); + } + const args = [ + LTX25_CUDA_HELPER_SCRIPT, + '--model-repo', model.repo, + '--model-revision', model.revision, + '--prompt', prompt, + '--width', String(width), + '--height', String(height), + '--num-frames', String(numFrames), + '--fps', String(fps), + '--steps', String(steps), + '--seed', String(seed), + '--output', outputPath, + ]; + for (const file of files) args.push('--repo-file', file); + if (sourceImagePath) args.push('--image', sourceImagePath, '--image-strength', String(imageStrength ?? 1)); + if (disableAudio) args.push('--disable-audio'); + return { bin: LTX25_CUDA_VENV_PYTHON, args }; +}; + export const buildMiniMaxH3Ref2vaArgs = ({ model, ref2vaModelPath, prompt, negativePrompt, width, height, numFrames, fps, steps, seed, sourceImagePath, audioFilePath, audioStartSec, mode, tiling, @@ -947,6 +1021,9 @@ export const buildArgs = ({ pythonPath, modelId, model, wanModelPath, wanRequire if (model.runtime === 'wan22') { return buildWan22Args({ model, wanModelPath, wanRequiredWeights, prompt, negativePrompt, width, height, numFrames, fps, steps, guidance, seed, sourceImagePath, mode, outputPath }); } + if (model.runtime === 'wan22_cuda') { + return buildWan22CudaArgs({ model, wanModelPath, prompt, negativePrompt, width, height, numFrames, fps, steps, guidance, seed, sourceImagePath, mode, outputPath }); + } if (model.runtime === 'minimax_h3') { return buildMiniMaxH3Args({ model, prompt, negativePrompt, width, height, numFrames, fps, steps, seed, sourceImagePath, lastImagePath, keyframes, extendFromVideoPath, audioFilePath, audioStartSec, icReferencePaths, mode, tiling, disableAudio, outputPath, previewDir, loras, textEncoder, draftDecoder }); } @@ -960,6 +1037,9 @@ export const buildArgs = ({ pythonPath, modelId, model, wanModelPath, wanRequire tiling, disableAudio, outputPath, ffmpegPath, ffprobePath, }); } + if (model.runtime === 'ltx25_cuda') { + return buildLtx25CudaArgs({ model, prompt, negativePrompt, width, height, numFrames, fps, steps, seed, sourceImagePath, mode, imageStrength, 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 diff --git a/server/services/videoGen/runtimes.js b/server/services/videoGen/runtimes.js index af9de09eb..3a6d1bfcd 100644 --- a/server/services/videoGen/runtimes.js +++ b/server/services/videoGen/runtimes.js @@ -113,6 +113,20 @@ export const FASTVIDEO_VENV_PYTHON = join(homedir(), '.portos', 'fastvideo', '.v export const FASTVIDEO_HELPER_SCRIPT = join(PATHS.root, 'scripts', 'generate_fastvideo.py'); export const FASTVIDEO_REPO_DIR = join(homedir(), '.portos', 'fastvideo'); +// LTX-2.5 on CUDA — the official Lightricks ltx-core / ltx-pipelines runtime. +export const LTX25_CUDA_REPO_DIR = join(homedir(), '.portos', 'ltx-2.5-cuda'); +export const LTX25_CUDA_VENV_PYTHON = process.platform === 'win32' + ? join(LTX25_CUDA_REPO_DIR, '.venv', 'Scripts', 'python.exe') + : join(LTX25_CUDA_REPO_DIR, '.venv', 'bin', 'python3'); +export const LTX25_CUDA_HELPER_SCRIPT = join(PATHS.root, 'scripts', 'generate_ltx25_cuda.py'); + +// Wan 2.2 TI2V 5B on CUDA — official Diffusers checkpoint. +export const WAN22_CUDA_REPO_DIR = join(homedir(), '.portos', 'wan2.2-cuda'); +export const WAN22_CUDA_VENV_PYTHON = process.platform === 'win32' + ? join(WAN22_CUDA_REPO_DIR, '.venv', 'Scripts', 'python.exe') + : join(WAN22_CUDA_REPO_DIR, '.venv', 'bin', 'python3'); +export const WAN22_CUDA_HELPER_SCRIPT = join(PATHS.root, 'scripts', 'generate_wan22_cuda.py'); + // Standalone runtime-fingerprint probe (scripts/runtime_fingerprint.py). Run in // each installed BYOV venv by resolveRuntimeFingerprint() to surface resolved // package versions on GET /api/video-gen/status without running a render. Shares @@ -224,6 +238,32 @@ 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'], }, + ltx25_cuda: { + id: 'ltx25_cuda', + label: 'LTX-2.5 CUDA', + venvPython: LTX25_CUDA_VENV_PYTHON, + repoDir: LTX25_CUDA_REPO_DIR, + installEnvVar: 'INSTALL_LTX25_CUDA', + cacheOnly: true, + killProcessGroup: true, + repoUrl: 'https://github.com/Lightricks/LTX-2', + installSourceLabel: 'official Lightricks ltx-core / ltx-pipelines packages', + importProbe: 'import sys, torch; import ltx_core, ltx_pipelines; from ltx_pipelines.distilled import DistilledPipeline; assert torch.cuda.is_available(), "no CUDA device"; assert sys.platform != "win32" or torch.__version__ == "2.10.0+cu128", f"expected torch 2.10.0+cu128 on Windows, got {torch.__version__}"', + fingerprintPackages: ['torch', 'ltx-core', 'ltx-pipelines', 'transformers', 'accelerate', 'huggingface-hub'], + }, + wan22_cuda: { + id: 'wan22_cuda', + label: 'Wan 2.2 CUDA', + venvPython: WAN22_CUDA_VENV_PYTHON, + repoDir: WAN22_CUDA_REPO_DIR, + installEnvVar: 'INSTALL_WAN22_CUDA', + cacheOnly: true, + killProcessGroup: true, + repoUrl: 'https://huggingface.co/docs/diffusers/main/api/pipelines/wan', + installSourceLabel: 'pinned Diffusers and CUDA PyTorch packages', + importProbe: 'import torch, hf_xet; from diffusers import AutoencoderKLWan, WanPipeline; assert torch.cuda.is_available(), "no CUDA device"', + fingerprintPackages: ['torch', 'diffusers', 'transformers', 'accelerate', 'huggingface-hub', 'hf-xet'], + }, wan22: { id: 'wan22', label: 'Wan 2.2 MLX', diff --git a/server/services/videoGen/runtimes.test.js b/server/services/videoGen/runtimes.test.js index dcce20ad2..a96a68e43 100644 --- a/server/services/videoGen/runtimes.test.js +++ b/server/services/videoGen/runtimes.test.js @@ -393,6 +393,44 @@ describe('minimax_h3_cuda runtime registration', () => { }); }); +describe('ltx25_cuda runtime registration', () => { + const info = BYOV_RUNTIME_INFO.ltx25_cuda; + + it('registers the official cache-only CUDA pipeline in its own venv', () => { + expect(BYOV_VIDEO_RUNTIMES.has('ltx25_cuda')).toBe(true); + expect(info.installEnvVar).toBe('INSTALL_LTX25_CUDA'); + expect(info.repoUrl).toBe('https://github.com/Lightricks/LTX-2'); + expect(info.venvPython).not.toBe(BYOV_RUNTIME_INFO.ltx25.venvPython); + expect(info.importProbe).toContain('DistilledPipeline'); + expect(info.importProbe).toContain('torch.cuda.is_available()'); + expect(info.importProbe).toContain('2.10.0+cu128'); + expect(info.cacheOnly).toBe(true); + expect(info.killProcessGroup).toBe(true); + }); + + it('keeps the cache contract aligned with the Python runner', () => { + const runner = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'scripts', 'generate_ltx25_cuda.py'), + 'utf8', + ); + for (const relative of [ + 'diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors', + 'text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors', + 'vae/ltx-2.5-video-vae-bf16.safetensors', + 'vae/ltx-2.5-audio-vae-bf16.safetensors', + 'model_patches/ltx-2.5-duration-head-bf16.safetensors', + 'latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors', + ]) { + expect(runner).toContain(relative); + } + expect(runner).toContain('local_files_only=True'); + expect(runner).toContain('PYTORCH_CUDA_ALLOC_CONF'); + expect(runner).toContain('OffloadMode.DISK'); + expect(runner).not.toContain('SafetensorsStateDictLoader.load ='); + expect(runner).not.toContain('pipe.prompt_encoder ='); + }); +}); + // 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. @@ -439,6 +477,8 @@ describe('runtime execution flags', () => { it('reports cache-only for exactly the runners that never touch the network', () => { expect(runtimeIsCacheOnly('minimax_h3')).toBe(true); expect(runtimeIsCacheOnly('minimax_h3_cuda')).toBe(true); + expect(runtimeIsCacheOnly('ltx25_cuda')).toBe(true); + expect(runtimeIsCacheOnly('wan22_cuda')).toBe(true); expect(runtimeIsCacheOnly('ltx2')).toBe(false); expect(runtimeIsCacheOnly('wan22')).toBe(false); expect(runtimeIsCacheOnly('fastvideo')).toBe(false); @@ -450,6 +490,8 @@ describe('runtime execution flags', () => { expect(runtimeNeedsProcessGroupKill('fastvideo')).toBe(true); expect(runtimeNeedsProcessGroupKill('minimax_h3')).toBe(true); expect(runtimeNeedsProcessGroupKill('minimax_h3_cuda')).toBe(true); + expect(runtimeNeedsProcessGroupKill('ltx25_cuda')).toBe(true); + expect(runtimeNeedsProcessGroupKill('wan22_cuda')).toBe(true); expect(runtimeNeedsProcessGroupKill('ltx2')).toBe(false); expect(runtimeNeedsProcessGroupKill('nope')).toBe(false); });