| """ |
| LoRA loading for HunyuanVideo 1.5 I2V. |
| |
| Unlike the Wan pipelines, `HunyuanVideo15ImageToVideoPipeline` does not inherit a LoRA loader |
| mixin, so LoRAs go straight onto the transformer through `PeftAdapterMixin` |
| (`load_lora_adapter` / `set_adapters` / `delete_adapters` / `fuse_lora`). |
| |
| Wan 2.2 LoRAs are NOT compatible: different architecture, different key names, and Wan's |
| high-noise/low-noise expert pairs have no counterpart here (HunyuanVideo 1.5 has a single |
| transformer). You need LoRAs trained for HunyuanVideo 1.5. |
| |
| The catalog is data, not code. Put a `loras.json` next to this file: |
| |
| [ |
| {"label": "My Style", "repo_id": "user/repo", "weight_name": "style.safetensors", "scale": 1.0}, |
| {"label": "Fused One", "repo_id": "user/repo2", "fuse_at_startup": true, "scale": 0.8} |
| ] |
| |
| or set LORA_CATALOG to the same JSON inline. Users can also type any repo into the |
| "Custom LoRA" box in the UI as `repo_id` or `repo_id:weight_name`. |
| """ |
| import json |
| import os |
|
|
| from huggingface_hub import hf_hub_download, snapshot_download |
|
|
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| CATALOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "loras.json") |
|
|
| _LOADED_ADAPTERS = [] |
|
|
|
|
| def _read_catalog(): |
| raw = os.environ.get("LORA_CATALOG") |
| if raw: |
| try: |
| return json.loads(raw) |
| except Exception as e: |
| print("LORA_CATALOG is not valid JSON:", e) |
| return [] |
| if os.path.exists(CATALOG_PATH): |
| try: |
| with open(CATALOG_PATH) as fh: |
| return json.load(fh) |
| except Exception as e: |
| print(f"Could not read {CATALOG_PATH}:", e) |
| return [] |
|
|
|
|
| CATALOG = {} |
| for _entry in _read_catalog(): |
| if not isinstance(_entry, dict) or not _entry.get("repo_id"): |
| continue |
| _label = _entry.get("label") or _entry["repo_id"].split("/")[-1] |
| CATALOG[_label] = _entry |
|
|
|
|
| def get_lora_choices(): |
| return sorted(CATALOG.keys()) |
|
|
|
|
| def _resolve_path(repo_id, weight_name=None, revision=None): |
| """Return a local path diffusers can load: a single file when weight_name is given, |
| otherwise the whole snapshot (diffusers will find the LoRA inside).""" |
| if weight_name: |
| return hf_hub_download(repo_id, weight_name, token=HF_TOKEN, revision=revision) |
| return snapshot_download(repo_id, token=HF_TOKEN, revision=revision) |
|
|
|
|
| def _parse_custom(spec): |
| """'repo/name' or 'repo/name:file.safetensors' -> (repo_id, weight_name|None)""" |
| spec = (spec or "").strip() |
| if not spec: |
| return None |
| if ":" in spec: |
| repo_id, weight_name = spec.split(":", 1) |
| return repo_id.strip(), weight_name.strip() or None |
| return spec, None |
|
|
|
|
| def load_loras_to_pipe(pipe, labels=None, custom=None, scale=1.0): |
| """Load the selected catalog entries plus an optional custom LoRA onto pipe.transformer. |
| |
| Returns True if at least one adapter was attached. Note that stacking runtime LoRAs on an |
| fp8-quantized transformer can fail depending on the torchao/peft versions; if that happens, |
| use `fuse_at_startup` in the catalog instead (fusion runs before quantization). |
| """ |
| unload_lora(pipe) |
|
|
| requests = [] |
| for label in (labels or []): |
| entry = CATALOG.get(label) |
| if not entry or entry.get("fuse_at_startup"): |
| continue |
| requests.append((label, entry.get("repo_id"), entry.get("weight_name"), |
| entry.get("revision"), float(entry.get("scale", 1.0)))) |
|
|
| parsed = _parse_custom(custom) |
| if parsed: |
| requests.append(("custom", parsed[0], parsed[1], None, 1.0)) |
|
|
| if not requests: |
| return False |
|
|
| names, weights = [], [] |
| for idx, (label, repo_id, weight_name, revision, entry_scale) in enumerate(requests): |
| path = _resolve_path(repo_id, weight_name, revision) |
| adapter_name = f"lora_{idx}" |
| pipe.transformer.load_lora_adapter(path, prefix="transformer", adapter_name=adapter_name) |
| names.append(adapter_name) |
| weights.append(entry_scale * float(scale)) |
| print(f"Loaded LoRA: {label} ({repo_id})") |
|
|
| pipe.transformer.set_adapters(names, weights=weights) |
| _LOADED_ADAPTERS[:] = names |
| return True |
|
|
|
|
| def unload_lora(pipe): |
| if not _LOADED_ADAPTERS: |
| return |
| try: |
| pipe.transformer.delete_adapters(list(_LOADED_ADAPTERS)) |
| except Exception: |
| try: |
| pipe.transformer.unload_lora() |
| except Exception: |
| pass |
| _LOADED_ADAPTERS.clear() |
|
|
|
|
| def fuse_startup_loras(pipe): |
| """Fuse catalog entries marked `fuse_at_startup` into the transformer weights. |
| |
| Call this once at import time, before quantization: fused weights survive fp8 conversion, |
| runtime adapters may not. Mirrors what the Wan reference space does with its Lightning LoRAs. |
| """ |
| entries = [e for e in CATALOG.values() if e.get("fuse_at_startup")] |
| if not entries: |
| return |
| for i, entry in enumerate(entries): |
| adapter_name = f"startup_{i}" |
| try: |
| path = _resolve_path(entry["repo_id"], entry.get("weight_name"), entry.get("revision")) |
| pipe.transformer.load_lora_adapter(path, prefix="transformer", adapter_name=adapter_name) |
| pipe.transformer.set_adapters([adapter_name], weights=[1.0]) |
| pipe.transformer.fuse_lora(lora_scale=float(entry.get("scale", 1.0)), |
| adapter_names=[adapter_name]) |
| pipe.transformer.delete_adapters([adapter_name]) |
| print(f"Fused LoRA at startup: {entry.get('label', entry['repo_id'])} " |
| f"(scale={entry.get('scale', 1.0)}), {i + 1}/{len(entries)}") |
| except Exception as e: |
| print("Error:", str(e)) |
| print("Failed LoRA:", entry.get("label", entry.get("repo_id"))) |
| try: |
| pipe.transformer.delete_adapters([adapter_name]) |
| except Exception: |
| pass |
|
|