| """Model file registry: maps filename -> (HuggingFace repo, subfolder). |
| |
| Lookups are by filename only — the same filename in two different repos is not |
| supported. If that ever happens we'll qualify by ComfyUI loader-type. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import pathlib |
| from collections.abc import Iterator |
| from dataclasses import dataclass |
|
|
| from huggingface_hub import hf_hub_download |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass(frozen=True) |
| class ModelEntry: |
| repo_id: str |
| subfolder: str = "" |
| comfy_type: str = "checkpoints" |
| |
| |
| |
| |
| |
| source_filename: str | None = None |
|
|
|
|
| MODEL_REGISTRY: dict[str, ModelEntry] = { |
| |
| "ltx-2.3-22b-distilled.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"), |
| "ltx-2.3-22b-dev.safetensors": ModelEntry("Lightricks/LTX-2.3", comfy_type="checkpoints"), |
| "ltx-2.3-spatial-upscaler-x2-1.0.safetensors": ModelEntry( |
| "Lightricks/LTX-2.3", comfy_type="latent_upscale_models" |
| ), |
| "ltx-2.3-22b-distilled-lora-384.safetensors": ModelEntry( |
| "Lightricks/LTX-2.3", comfy_type="loras" |
| ), |
| |
| **{ |
| f"model-{i:05d}-of-00005.safetensors": ModelEntry( |
| "google/gemma-3-12b-it-qat-q4_0-unquantized", |
| comfy_type="text_encoders", |
| subfolder="gemma-3-12b-it", |
| ) |
| for i in range(1, 6) |
| }, |
| "model.safetensors.index.json": ModelEntry( |
| "google/gemma-3-12b-it-qat-q4_0-unquantized", |
| comfy_type="text_encoders", |
| subfolder="gemma-3-12b-it", |
| ), |
| "tokenizer.model": ModelEntry( |
| "google/gemma-3-12b-it-qat-q4_0-unquantized", |
| comfy_type="text_encoders", |
| subfolder="gemma-3-12b-it", |
| ), |
| "preprocessor_config.json": ModelEntry( |
| "google/gemma-3-12b-it-qat-q4_0-unquantized", |
| comfy_type="text_encoders", |
| subfolder="gemma-3-12b-it", |
| ), |
| |
| |
| "LTX23_video_vae_bf16.safetensors": ModelEntry( |
| "Kijai/LTX2.3_comfy", subfolder="vae", comfy_type="vae" |
| ), |
| "LTX23_audio_vae_bf16.safetensors": ModelEntry( |
| "Kijai/LTX2.3_comfy", subfolder="vae", comfy_type="vae" |
| ), |
| "ltx-2.3_text_projection_bf16.safetensors": ModelEntry( |
| "Kijai/LTX2.3_comfy", subfolder="text_encoders", comfy_type="text_encoders" |
| ), |
| |
| "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors": ModelEntry( |
| "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control", comfy_type="loras" |
| ), |
| "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors": ModelEntry( |
| "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control", comfy_type="loras" |
| ), |
| "ltx-2-19b-ic-lora-detailer.safetensors": ModelEntry( |
| "Lightricks/LTX-2-19b-IC-LoRA-Detailer", comfy_type="loras" |
| ), |
| "ltx-2-19b-ic-lora-pose-control.safetensors": ModelEntry( |
| "Lightricks/LTX-2-19b-IC-LoRA-Pose-Control", comfy_type="loras" |
| ), |
| |
| |
| **{ |
| f"ltx-2-19b-lora-camera-control-{movement}.safetensors": ModelEntry( |
| f"Lightricks/LTX-2-19b-LoRA-Camera-Control-{'-'.join(p.capitalize() for p in movement.split('-'))}", |
| comfy_type="loras", |
| ) |
| for movement in ( |
| "static", |
| "dolly-in", |
| "dolly-out", |
| "dolly-left", |
| "dolly-right", |
| "jib-up", |
| "jib-down", |
| ) |
| }, |
| |
| |
| |
| |
| |
| |
| |
| |
| "gemma_3_12B_it_fp4_mixed.safetensors": ModelEntry( |
| |
| |
| |
| "Comfy-Org/ltx-2", |
| subfolder="split_files/text_encoders", |
| comfy_type="text_encoders", |
| source_filename="gemma_3_12B_it.safetensors", |
| ), |
| "gemma_3_12B_it.safetensors": ModelEntry( |
| "Comfy-Org/ltx-2", |
| subfolder="split_files/text_encoders", |
| comfy_type="text_encoders", |
| ), |
| "ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors": ModelEntry( |
| |
| "Kijai/LTX2.3_comfy", |
| subfolder="diffusion_models", |
| comfy_type="diffusion_models", |
| source_filename="ltx-2.3-22b-dev_transformer_only_bf16.safetensors", |
| ), |
| "ltx-2-3-22b-dev-Q4_K_M.gguf": ModelEntry( |
| |
| "unsloth/LTX-2.3-GGUF", |
| comfy_type="diffusion_models", |
| source_filename="ltx-2.3-22b-dev-BF16.gguf", |
| ), |
| "taeltx2_3.safetensors": ModelEntry( |
| "Kijai/LTX2.3_comfy", |
| subfolder="vae", |
| comfy_type="vae", |
| ), |
| "ltx-2.3-22b-distilled-lora-dynamic_fro09_avg_rank_105_bf16.safetensors": ModelEntry( |
| "Kijai/LTX2.3_comfy", |
| subfolder="loras", |
| comfy_type="loras", |
| ), |
| } |
|
|
|
|
| LOADER_NODE_TYPES: tuple[str, ...] = ( |
| "CheckpointLoaderSimple", |
| "UNETLoader", |
| "UnetLoaderGGUF", |
| "VAELoader", |
| "VAELoaderKJ", |
| "LoraLoader", |
| "Power Lora Loader (rgthree)", |
| "LTXVGemmaCLIPModelLoader", |
| "LatentUpscaleModelLoader", |
| "DualCLIPLoader", |
| ) |
|
|
|
|
| _USER_INPUT_LOADERS = {"LoadImage", "VHS_LoadVideo", "VHS_LoadAudioUpload"} |
| _MODEL_EXTS = (".safetensors", ".gguf", ".pt", ".bin", ".ckpt") |
|
|
|
|
| def _walk_for_filenames(value, into: set[str]) -> None: |
| """Depth-first walk of a node's inputs, picking out model filenames. |
| |
| Power Lora Loader stores its rows nested as `inputs.lora_1 = {on, lora, |
| strength}` and similar — a flat values() loop misses these. Recurse |
| through dicts and lists/tuples so nested filenames are caught. |
| |
| Skips Power Lora Loader rows with `on: false` — those LoRAs aren't |
| actually loaded at runtime so there's no point downloading them. |
| """ |
| if isinstance(value, str): |
| if value.endswith(_MODEL_EXTS) or value == "tokenizer.model": |
| into.add(value) |
| elif isinstance(value, dict): |
| |
| if "on" in value and "lora" in value and not value.get("on"): |
| return |
| for v in value.values(): |
| _walk_for_filenames(v, into) |
| elif isinstance(value, (list, tuple)): |
| for v in value: |
| _walk_for_filenames(v, into) |
|
|
|
|
| def walk_workflow_for_models(workflow: dict) -> set[str]: |
| """Return the set of model filenames referenced by the API-format workflow. |
| |
| Walks `{node_id: {class_type, inputs}}` and recursively scans each node's |
| inputs for strings ending in a model extension. Skips loaders that read |
| user-supplied files (LoadImage, VHS_LoadVideo, VHS_LoadAudioUpload). |
| Unknown filenames are harmless — `ensure_models` log-warns and skips |
| anything not in the registry, so being inclusive here costs nothing. |
| """ |
| needed: set[str] = set() |
| for node in workflow.values(): |
| if not isinstance(node, dict): |
| continue |
| if node.get("class_type") in _USER_INPUT_LOADERS: |
| continue |
| _walk_for_filenames(node.get("inputs") or {}, needed) |
| return needed |
|
|
|
|
| @dataclass |
| class DownloadEvent: |
| filename: str |
| mb_done: float |
| mb_total: float |
|
|
|
|
| def _on_spaces() -> bool: |
| return bool(os.environ.get("SPACES_ZERO_GPU")) |
|
|
|
|
| def _comfy_models_dir() -> pathlib.Path: |
| raw = os.environ.get("COMFY_MODELS_DIR") |
| if raw: |
| return pathlib.Path(raw) |
| if _on_spaces(): |
| return pathlib.Path.home() / "comfyui" / "models" |
| return pathlib.Path(__file__).parent / "comfyui" / "models" |
|
|
|
|
| def ensure_models(filenames: set[str]) -> Iterator[DownloadEvent]: |
| """Ensure each requested model is materialized in comfyui/models/<type>/. |
| |
| Local mode: hf_hub_download into the user's HF cache; symlink to comfyui/models/. |
| Spaces mode: hf_hub_download with cache_dir under $HOME (no /data dependency); |
| files staged at ~/comfyui/models/<comfy_type>/<filename>. |
| |
| Files not in MODEL_REGISTRY are skipped (with a warning) — useful when the |
| workflow has been manually customized with non-canonical filenames that the |
| user supplies via their own ComfyUI install. |
| |
| Yields DownloadEvent on each successfully materialized file (mb_done==mb_total |
| when already cached locally). |
| """ |
| comfy_models = _comfy_models_dir() |
| cache_dir = pathlib.Path( |
| os.environ.get( |
| "HF_HUB_CACHE", |
| pathlib.Path.home() / ".cache" / "huggingface" / "hub", |
| ) |
| ) |
|
|
| for filename in filenames: |
| if filename not in MODEL_REGISTRY: |
| logger.warning( |
| "model file %r not in MODEL_REGISTRY; skipping. " |
| "Add an entry to MODEL_REGISTRY or override the loader in the workflow.", |
| filename, |
| ) |
| continue |
| entry = MODEL_REGISTRY[filename] |
|
|
| |
| |
| |
| |
| existing_dest = comfy_models / entry.comfy_type / filename |
| if existing_dest.exists() or existing_dest.is_symlink(): |
| yield DownloadEvent(filename, 0.0, 0.0) |
| continue |
|
|
| |
| |
| |
| hf_filename = entry.source_filename or filename |
| hf_path = f"{entry.subfolder}/{hf_filename}" if entry.subfolder else hf_filename |
|
|
| try: |
| source = pathlib.Path( |
| hf_hub_download( |
| repo_id=entry.repo_id, |
| filename=hf_path, |
| cache_dir=str(cache_dir), |
| local_dir=None, |
| ) |
| ) |
| size_mb = source.stat().st_size / 1024 / 1024 |
| yield DownloadEvent(filename, size_mb, size_mb) |
| except Exception as exc: |
| |
| |
| |
| |
| |
| |
| def _viable(path): |
| try: |
| return ".no_exist" not in path.parts and path.stat().st_size > 64 |
| except OSError: |
| return False |
|
|
| candidates = [ |
| p for p in cache_dir.rglob(filename) if _viable(p) |
| ] or [ |
| p for p in cache_dir.rglob(hf_filename) if _viable(p) |
| ] |
| if not candidates: |
| logger.warning( |
| "could not download or locate %r (hf=%r) in HF cache: %s; skipping", |
| filename, hf_filename, exc, |
| ) |
| continue |
| source = candidates[0] |
| yield DownloadEvent(filename, 0.0, 0.0) |
|
|
| |
| dest_dir = comfy_models / entry.comfy_type |
| dest_dir.mkdir(parents=True, exist_ok=True) |
| dest = dest_dir / filename |
|
|
| if dest.is_symlink() or dest.exists(): |
| dest.unlink() |
| dest.symlink_to(source) |
|
|
|
|
| def ensure_models_for_mode(mode: str) -> Iterator[DownloadEvent]: |
| """Convenience: walk a mode's workflow and ensure all referenced models exist.""" |
| import workflow as workflow_module |
|
|
| wf = workflow_module.load_template(mode) |
| needed = walk_workflow_for_models(wf) |
| yield from ensure_models(needed) |
|
|