Spaces:
Running on Zero
Running on Zero
| """Prepare the ComfyUI runtime used by the VoiceGate Space. | |
| The script is intentionally idempotent. By default it prepares ComfyUI and | |
| custom node repositories, but it does not download large model weights unless | |
| explicitly requested. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[1] | |
| COMFY_DIR = ROOT / "ComfyUI" | |
| CUSTOM_NODES_DIR = COMFY_DIR / "custom_nodes" | |
| DEFAULT_PERSISTENT_MODEL_ROOT = Path("/data/voicegate_models") | |
| MELBAND_FILENAME = "MelBandRoformer_fp32.safetensors" | |
| MELBAND_SIZE = 912_885_656 | |
| MELBAND_SHA256 = "450caec8e8e261ff79426f17ccf16d43490ba4b790ff84d573083cf94e111258" | |
| class GitRepo: | |
| name: str | |
| url: str | |
| commit: str | |
| target: Path | |
| requirements: str | None = "requirements.txt" | |
| COMFYUI = GitRepo( | |
| name="ComfyUI", | |
| url="https://github.com/comfyanonymous/ComfyUI.git", | |
| commit="5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e", | |
| target=COMFY_DIR, | |
| ) | |
| CUSTOM_NODE_REPOS = [ | |
| GitRepo( | |
| name="comfyui_voicebridge", | |
| url="https://github.com/YanTianlong-01/comfyui_voicebridge.git", | |
| commit="3728962c0db7b9e05a1d0b341e3dbbd8adba4409", | |
| target=CUSTOM_NODES_DIR / "comfyui_voicebridge", | |
| ), | |
| GitRepo( | |
| name="ComfyUI_RH_VoxCPM", | |
| url="https://github.com/RH-RunningHub/ComfyUI_RH_VoxCPM.git", | |
| commit="8365fe0e1fa60d7547f83ae5db53453a3d9c627d", | |
| target=CUSTOM_NODES_DIR / "ComfyUI_RH_VoxCPM", | |
| ), | |
| GitRepo( | |
| name="ComfyUI-MelBandRoFormer", | |
| url="https://github.com/kijai/ComfyUI-MelBandRoFormer.git", | |
| commit="92c86854e6654f4aacc97484471af95c98ea16d4", | |
| target=CUSTOM_NODES_DIR / "ComfyUI-MelBandRoFormer", | |
| ), | |
| GitRepo( | |
| name="ComfyUI_RH_LLM_API", | |
| url="https://github.com/HM-RunningHub/ComfyUI_RH_LLM_API.git", | |
| commit="26e18d1a769bd08e115b59bfdf170f8a2166c0df", | |
| target=CUSTOM_NODES_DIR / "ComfyUI_RH_LLM_API", | |
| requirements=None, | |
| ), | |
| GitRepo( | |
| name="rgthree-comfy", | |
| url="https://github.com/rgthree/rgthree-comfy.git", | |
| commit="738105af5fb14e96fbecaf406dc356e284797e8c", | |
| target=CUSTOM_NODES_DIR / "rgthree-comfy", | |
| ), | |
| GitRepo( | |
| name="ComfyUI-Easy-Use", | |
| url="https://github.com/yolain/ComfyUI-Easy-Use.git", | |
| commit="625efbfa2fc20c31797dfffcbb41a26b6d91ab7b", | |
| target=CUSTOM_NODES_DIR / "ComfyUI-Easy-Use", | |
| ), | |
| GitRepo( | |
| name="ComfyUI_Comfyroll_CustomNodes", | |
| url="https://github.com/Suzie1/ComfyUI_Comfyroll_CustomNodes.git", | |
| commit="d78b780ae43fcf8c6b7c6505e6ffb4584281ceca", | |
| target=CUSTOM_NODES_DIR / "ComfyUI_Comfyroll_CustomNodes", | |
| requirements=None, | |
| ), | |
| GitRepo( | |
| name="ComfyUI_AudioTools", | |
| url="https://github.com/billwuhao/ComfyUI_AudioTools.git", | |
| commit="41463715b476aa1d44de617119a68d8841aa04bd", | |
| target=CUSTOM_NODES_DIR / "ComfyUI_AudioTools", | |
| ), | |
| ] | |
| MODEL_LINKS = { | |
| "voxcpm2": COMFY_DIR / "models" / "voxcpm" / "VoxCPM2", | |
| "melband": COMFY_DIR / "models" / "diffusion_models" / "MelBandRoFormer_comfy", | |
| "qwen3_asr": COMFY_DIR / "models" / "Qwen3-ASR", | |
| } | |
| MODEL_DIRS = { | |
| "voxcpm2": Path("voxcpm") / "VoxCPM2", | |
| "melband": Path("diffusion_models") / "MelBandRoFormer_comfy", | |
| "qwen3_asr": Path("Qwen3-ASR"), | |
| } | |
| def model_root() -> Path: | |
| configured = os.environ.get("VOICEGATE_MODEL_ROOT") | |
| if configured: | |
| return Path(configured) | |
| if DEFAULT_PERSISTENT_MODEL_ROOT.parent.exists(): | |
| return DEFAULT_PERSISTENT_MODEL_ROOT | |
| return COMFY_DIR / "models" | |
| def model_target(name: str) -> Path: | |
| return model_root() / MODEL_DIRS[name] | |
| def redacted(cmd: list[str]) -> list[str]: | |
| result = cmd[:] | |
| for index, value in enumerate(result[:-1]): | |
| if value == "--token": | |
| result[index + 1] = "***" | |
| return result | |
| def run(cmd: list[str], cwd: Path | None = None, dry_run: bool = False) -> None: | |
| printable = " ".join(redacted(cmd)) | |
| prefix = f"cd {cwd} && " if cwd else "" | |
| print(f"+ {prefix}{printable}", flush=True) | |
| if not dry_run: | |
| subprocess.run(cmd, cwd=cwd, check=True) | |
| def ensure_git_repo(repo: GitRepo, dry_run: bool = False) -> None: | |
| if not dry_run: | |
| repo.target.parent.mkdir(parents=True, exist_ok=True) | |
| if repo.target.exists() and not (repo.target / ".git").exists(): | |
| raise RuntimeError(f"{repo.target} exists but is not a git repository") | |
| if not repo.target.exists(): | |
| run(["git", "clone", repo.url, str(repo.target)], dry_run=dry_run) | |
| run(["git", "fetch", "--all", "--tags"], cwd=repo.target, dry_run=dry_run) | |
| run(["git", "checkout", repo.commit], cwd=repo.target, dry_run=dry_run) | |
| def install_requirements(repo: GitRepo, dry_run: bool = False) -> None: | |
| if not repo.requirements: | |
| return | |
| requirements = repo.target / repo.requirements | |
| if not requirements.exists(): | |
| return | |
| run( | |
| [sys.executable, "-m", "pip", "install", "-r", str(requirements)], | |
| dry_run=dry_run, | |
| ) | |
| def ensure_model_link(name: str, dry_run: bool = False) -> None: | |
| target = model_target(name) | |
| link = MODEL_LINKS[name] | |
| print(f"+ mkdir -p {target}") | |
| print(f"+ link {link} -> {target}") | |
| if dry_run: | |
| return | |
| target.mkdir(parents=True, exist_ok=True) | |
| link.parent.mkdir(parents=True, exist_ok=True) | |
| if link.exists() or link.is_symlink(): | |
| if link.resolve() == target.resolve(): | |
| return | |
| if link.is_dir() and not link.is_symlink() and not any(link.iterdir()): | |
| link.rmdir() | |
| else: | |
| raise RuntimeError(f"{link} exists and does not point to {target}") | |
| link.symlink_to(target, target_is_directory=True) | |
| def prepare_model_dirs(dry_run: bool = False) -> None: | |
| for name in MODEL_LINKS: | |
| ensure_model_link(name, dry_run=dry_run) | |
| def file_sha256(path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with path.open("rb") as file: | |
| for chunk in iter(lambda: file.read(8 * 1024 * 1024), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def melband_model_path() -> Path: | |
| return model_target("melband") / MELBAND_FILENAME | |
| def validate_melband_model(*, verify_hash: bool = True) -> tuple[bool, str]: | |
| path = melband_model_path() | |
| if not path.is_file(): | |
| return False, "missing" | |
| size = path.stat().st_size | |
| if size != MELBAND_SIZE: | |
| return False, f"size_mismatch expected={MELBAND_SIZE} actual={size}" | |
| if verify_hash: | |
| try: | |
| digest = file_sha256(path) | |
| except OSError as exc: | |
| return False, f"read_error {type(exc).__name__}: {exc}" | |
| if digest != MELBAND_SHA256: | |
| return False, f"sha256_mismatch expected={MELBAND_SHA256} actual={digest}" | |
| return True, "ok" | |
| def patch_melband_loader(dry_run: bool = False) -> None: | |
| """Avoid safetensors mmap on persistent Space storage. | |
| ComfyUI's generic loader uses safetensors.safe_open(), which memory maps the | |
| model file. A damaged file or an unstable mmap on /data can terminate the | |
| interpreter with SIGBUS before Python can report a normal exception. | |
| Loading from bytes uses regular reads and turns corruption into a catchable | |
| safetensors error instead. | |
| """ | |
| nodes_path = CUSTOM_NODES_DIR / "ComfyUI-MelBandRoFormer" / "nodes.py" | |
| print(f"+ patch non-mmap MelBand loader: {nodes_path}", flush=True) | |
| if dry_run: | |
| return | |
| if not nodes_path.is_file(): | |
| raise RuntimeError(f"MelBand node file is missing: {nodes_path}") | |
| text = nodes_path.read_text(encoding="utf-8") | |
| if "load_safetensors_bytes" not in text: | |
| text = text.replace( | |
| "import torchaudio.functional as TAF\n", | |
| "import torchaudio.functional as TAF\n" | |
| "from safetensors.torch import load as load_safetensors_bytes\n", | |
| ) | |
| text = text.replace( | |
| "model.load_state_dict(load_torch_file(model_path), strict=True)", | |
| "with open(model_path, \"rb\") as model_file:\n" | |
| " state_dict = load_safetensors_bytes(model_file.read())\n" | |
| " model.load_state_dict(state_dict, strict=True)", | |
| ) | |
| if "load_safetensors_bytes" not in text or "state_dict = load_safetensors_bytes" not in text: | |
| raise RuntimeError("Could not apply the non-mmap MelBand loader patch") | |
| nodes_path.write_text(text, encoding="utf-8") | |
| def download_models(dry_run: bool = False) -> None: | |
| """Download large model assets. | |
| This is opt-in because the model set is large. Keep downloads separate from | |
| plain bootstrap so we can test repository setup without consuming Space | |
| storage and startup time. | |
| """ | |
| token = os.environ.get("HF_TOKEN") | |
| if dry_run: | |
| print(f"+ snapshot_download openbmb/VoxCPM2 -> {model_target('voxcpm2')}") | |
| print( | |
| "+ hf_hub_download " | |
| f"Kijai/MelBandRoFormer_comfy/MelBandRoformer_fp32.safetensors -> {model_target('melband')}" | |
| ) | |
| print(f"+ snapshot_download Qwen/Qwen3-ASR-1.7B -> {model_target('qwen3_asr') / 'Qwen3-ASR-1.7B'}") | |
| print( | |
| "+ snapshot_download " | |
| f"Qwen/Qwen3-ForcedAligner-0.6B -> {model_target('qwen3_asr') / 'Qwen3-ForcedAligner-0.6B'}" | |
| ) | |
| return | |
| from huggingface_hub import hf_hub_download, snapshot_download | |
| snapshot_download( | |
| repo_id="openbmb/VoxCPM2", | |
| local_dir=model_target("voxcpm2"), | |
| token=token, | |
| ) | |
| melband_valid, melband_reason = validate_melband_model(verify_hash=True) | |
| print(f"+ validate MelBand model: {melband_reason}", flush=True) | |
| if not melband_valid and melband_model_path().exists(): | |
| print(f"+ remove invalid MelBand model: {melband_model_path()}", flush=True) | |
| melband_model_path().unlink() | |
| hf_hub_download( | |
| repo_id="Kijai/MelBandRoFormer_comfy", | |
| filename=MELBAND_FILENAME, | |
| local_dir=model_target("melband"), | |
| token=token, | |
| force_download=not melband_valid, | |
| ) | |
| melband_valid, melband_reason = validate_melband_model(verify_hash=True) | |
| print(f"+ verify downloaded MelBand model: {melband_reason}", flush=True) | |
| if not melband_valid: | |
| raise RuntimeError(f"MelBand model validation failed: {melband_reason}") | |
| snapshot_download( | |
| repo_id="Qwen/Qwen3-ASR-1.7B", | |
| local_dir=model_target("qwen3_asr") / "Qwen3-ASR-1.7B", | |
| token=token, | |
| ) | |
| snapshot_download( | |
| repo_id="Qwen/Qwen3-ForcedAligner-0.6B", | |
| local_dir=model_target("qwen3_asr") / "Qwen3-ForcedAligner-0.6B", | |
| token=token, | |
| ) | |
| def print_summary() -> None: | |
| print("\nRuntime layout:") | |
| print(f" ComfyUI: {COMFY_DIR}") | |
| print(f" custom_nodes: {CUSTOM_NODES_DIR}") | |
| print(f" model_root: {model_root()}") | |
| for name, path in MODEL_LINKS.items(): | |
| print(f" model:{name}: {path} -> {model_target(name)}") | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--dry-run", action="store_true") | |
| parser.add_argument("--skip-pip", action="store_true") | |
| parser.add_argument("--with-models", action="store_true") | |
| parser.add_argument("--clean", action="store_true") | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| if args.clean: | |
| if COMFY_DIR.exists(): | |
| print(f"Removing {COMFY_DIR}") | |
| if not args.dry_run: | |
| shutil.rmtree(COMFY_DIR) | |
| ensure_git_repo(COMFYUI, dry_run=args.dry_run) | |
| if not args.dry_run: | |
| CUSTOM_NODES_DIR.mkdir(parents=True, exist_ok=True) | |
| for repo in CUSTOM_NODE_REPOS: | |
| ensure_git_repo(repo, dry_run=args.dry_run) | |
| patch_melband_loader(dry_run=args.dry_run) | |
| if not args.skip_pip: | |
| install_requirements(COMFYUI, dry_run=args.dry_run) | |
| for repo in CUSTOM_NODE_REPOS: | |
| install_requirements(repo, dry_run=args.dry_run) | |
| prepare_model_dirs(dry_run=args.dry_run) | |
| if args.with_models: | |
| download_models(dry_run=args.dry_run) | |
| print_summary() | |
| if __name__ == "__main__": | |
| main() | |