Spaces:
Running on Zero
Running on Zero
fix: cached LoRAs fully wired — dropdown discovers /data/loras/*.safetensors, loads direct from disk
Browse files- _community_lora_choices() scans bucket cache dir for .safetensors not
in registry, shows them with cache: prefix
- _load_lora_from_cache() loads safetensors by path, detects format
(ComfyUI/kohya/LoKR), converts, stores in _CUSTOM_LoRA
- _download_custom_lora() detects cache: prefix, skips download
- _generate_impl() handles cache: keys for the generate pipeline
- ~100 previously-invisible cached LoRAs now searchable in dropdown
app.py
CHANGED
|
@@ -1532,6 +1532,24 @@ def _download_custom_lora(url, scale=1.0, trigger="", description="", tags=""):
|
|
| 1532 |
|
| 1533 |
On success the LoRA is also added to the community registry so other
|
| 1534 |
users can discover it via the searchable dropdown."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1535 |
try:
|
| 1536 |
repo, file = _parse_hf_link(url)
|
| 1537 |
if file is None:
|
|
@@ -1600,8 +1618,9 @@ def _community_lora_choices():
|
|
| 1600 |
"""Return (label, value) pairs for the community LoRA dropdown.
|
| 1601 |
|
| 1602 |
Sources: the JSON registry **plus** any ``.safetensors`` files already cached
|
| 1603 |
-
in ``/data/loras/`` that aren't in the registry yet
|
| 1604 |
-
|
|
|
|
| 1605 |
import glob as _glob, os
|
| 1606 |
registry = _load_community_registry()
|
| 1607 |
known_urls = {e.get("url", "") for e in registry}
|
|
@@ -1617,22 +1636,70 @@ def _community_lora_choices():
|
|
| 1617 |
label += f" [{', '.join(tags[:3])}]"
|
| 1618 |
choices.append((label, entry.get("url", "")))
|
| 1619 |
# Auto-discover cached .safetensors files not yet in the registry.
|
| 1620 |
-
for path in _glob.glob(os.path.join(_LORA_CACHE_DIR, "*.safetensors")):
|
| 1621 |
fname = os.path.basename(path)
|
| 1622 |
-
#
|
| 1623 |
-
#
|
|
|
|
|
|
|
|
|
|
| 1624 |
stem = fname[:-len(".safetensors")]
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
repo = parts[0].replace("_", "/") # best-effort reverse
|
| 1628 |
-
file = parts[1]
|
| 1629 |
-
url = f"https://huggingface.co/{repo}/resolve/main/{file}"
|
| 1630 |
-
if url not in known_urls and (repo, file) not in known_keys:
|
| 1631 |
-
name = repo.split("/")[-1]
|
| 1632 |
-
choices.append((f"{name} (cached)", url))
|
| 1633 |
return choices
|
| 1634 |
|
| 1635 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1636 |
def _submit_community_lora(url, scale, trigger, description, tags):
|
| 1637 |
"""Add a LoRA to the community registry and return updated dropdown choices + status."""
|
| 1638 |
import json
|
|
@@ -1670,9 +1737,15 @@ def _submit_community_lora(url, scale, trigger, description, tags):
|
|
| 1670 |
|
| 1671 |
|
| 1672 |
def _load_selected_community_lora(evt: gr.SelectData):
|
| 1673 |
-
"""When a community LoRA is selected from the dropdown, return its URL / scale / trigger.
|
|
|
|
|
|
|
|
|
|
| 1674 |
registry = _load_community_registry()
|
| 1675 |
url = evt.value
|
|
|
|
|
|
|
|
|
|
| 1676 |
for entry in registry:
|
| 1677 |
e_url = entry.get("url", "")
|
| 1678 |
if e_url == url or f"https://huggingface.co/{entry.get('repo', '')}/resolve/main/{entry.get('file', '')}" == url:
|
|
@@ -2040,19 +2113,35 @@ def _generate_impl(prompt, image_path=None, last_image_path=None, canvas=DEFAULT
|
|
| 2040 |
# User-supplied LoRA from the custom URL box: parse the link and require it was downloaded & verified.
|
| 2041 |
custom = []
|
| 2042 |
if custom_lora_url and str(custom_lora_url).strip():
|
| 2043 |
-
|
| 2044 |
-
|
| 2045 |
-
|
| 2046 |
-
|
| 2047 |
-
|
| 2048 |
-
|
| 2049 |
-
|
| 2050 |
-
|
| 2051 |
-
|
| 2052 |
-
|
| 2053 |
-
|
| 2054 |
-
|
| 2055 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2056 |
|
| 2057 |
if nsfw_keys or custom:
|
| 2058 |
nsfw_keys += ("nsfw_aio",)
|
|
|
|
| 1532 |
|
| 1533 |
On success the LoRA is also added to the community registry so other
|
| 1534 |
users can discover it via the searchable dropdown."""
|
| 1535 |
+
# Handle cached LoRAs — load directly from local path, skip download.
|
| 1536 |
+
if url.startswith("cache:"):
|
| 1537 |
+
cache_path = url[len("cache:"):]
|
| 1538 |
+
try:
|
| 1539 |
+
key = _load_lora_from_cache(cache_path)
|
| 1540 |
+
except gr.Error as _e:
|
| 1541 |
+
return gr.update(value=f"**❌ {_e}**", visible=True)
|
| 1542 |
+
except Exception as _e:
|
| 1543 |
+
return gr.update(value=f"**❌ Unexpected error loading cached LoRA: {_e}**", visible=True)
|
| 1544 |
+
n = len(_CUSTOM_LoRA[key])
|
| 1545 |
+
fname = os.path.basename(cache_path)
|
| 1546 |
+
note = f" (scale {scale:g}" + (f", trigger `{trigger}`)" if trigger else ")")
|
| 1547 |
+
choices = _community_lora_choices()
|
| 1548 |
+
_log(f"cached LoRA {fname} loaded ({n} modules)")
|
| 1549 |
+
return (
|
| 1550 |
+
gr.update(value=f"**✅ Loaded `{fname}`** — {n} modules, will apply on Generate{note}.", visible=True),
|
| 1551 |
+
gr.update(choices=choices, value=url),
|
| 1552 |
+
)
|
| 1553 |
try:
|
| 1554 |
repo, file = _parse_hf_link(url)
|
| 1555 |
if file is None:
|
|
|
|
| 1618 |
"""Return (label, value) pairs for the community LoRA dropdown.
|
| 1619 |
|
| 1620 |
Sources: the JSON registry **plus** any ``.safetensors`` files already cached
|
| 1621 |
+
in ``/data/loras/`` that aren't in the registry yet. Cached files get a
|
| 1622 |
+
``cache:`` prefixed value so the download/load path can load them directly
|
| 1623 |
+
without going through the URL → download flow."""
|
| 1624 |
import glob as _glob, os
|
| 1625 |
registry = _load_community_registry()
|
| 1626 |
known_urls = {e.get("url", "") for e in registry}
|
|
|
|
| 1636 |
label += f" [{', '.join(tags[:3])}]"
|
| 1637 |
choices.append((label, entry.get("url", "")))
|
| 1638 |
# Auto-discover cached .safetensors files not yet in the registry.
|
| 1639 |
+
for path in sorted(_glob.glob(os.path.join(_LORA_CACHE_DIR, "*.safetensors"))):
|
| 1640 |
fname = os.path.basename(path)
|
| 1641 |
+
# Check if this file is already represented in the registry by matching
|
| 1642 |
+
# any URL that contains this filename.
|
| 1643 |
+
if any(fname in url for url in known_urls):
|
| 1644 |
+
continue
|
| 1645 |
+
# Extract a readable name from the filename (strip .safetensors, replace _ with space)
|
| 1646 |
stem = fname[:-len(".safetensors")]
|
| 1647 |
+
label = stem.replace("_", " ")
|
| 1648 |
+
choices.append((f"📦 {label}", f"cache:{path}"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1649 |
return choices
|
| 1650 |
|
| 1651 |
|
| 1652 |
+
def _load_lora_from_cache(path):
|
| 1653 |
+
"""Load a cached .safetensors LoRA directly by file path. Returns (repo, file) key
|
| 1654 |
+
and populates ``_CUSTOM_LoRA`` so it can be merged on generate."""
|
| 1655 |
+
import torch
|
| 1656 |
+
from safetensors.torch import load_file as _load_sd
|
| 1657 |
+
_t0 = time.time()
|
| 1658 |
+
fname = os.path.basename(path)
|
| 1659 |
+
# Use the filename as a synthetic (repo, file) key.
|
| 1660 |
+
key = ("__cache__", fname)
|
| 1661 |
+
if key in _CUSTOM_LoRA:
|
| 1662 |
+
return key
|
| 1663 |
+
try:
|
| 1664 |
+
_raw = _load_sd(path)
|
| 1665 |
+
except Exception as _e:
|
| 1666 |
+
raise gr.Error(f"Couldn't load cached LoRA `{fname}`: {_e}")
|
| 1667 |
+
# Detect format and convert.
|
| 1668 |
+
_has_comfy = any(".lora_A." in _k for _k in _raw)
|
| 1669 |
+
_has_kohya = any(".lora_down.weight" in _k or _k.startswith("lora_unet_") for _k in _raw)
|
| 1670 |
+
_has_lokr = any(".lokr_w1" in _k or ".lokr_w1_a" in _k for _k in _raw)
|
| 1671 |
+
if _has_comfy:
|
| 1672 |
+
_sd = _convert_comfyui_h3_lora(_raw)
|
| 1673 |
+
elif _has_lokr:
|
| 1674 |
+
_sd = _convert_lokr_h3_lora(_raw)
|
| 1675 |
+
elif _has_kohya:
|
| 1676 |
+
_sd = _convert_kohya_h3_lora(_raw)
|
| 1677 |
+
else:
|
| 1678 |
+
raise gr.Error(f"`{fname}` doesn't look like a MiniMax-H3 LoRA.")
|
| 1679 |
+
_pairs: dict[str, dict[str, torch.Tensor]] = {}
|
| 1680 |
+
for _k, _v in _sd.items():
|
| 1681 |
+
if ".lora_A." in _k:
|
| 1682 |
+
_pairs.setdefault(_k.split(".lora_A.")[0], {})["A"] = _v
|
| 1683 |
+
elif ".lora_B." in _k:
|
| 1684 |
+
_pairs.setdefault(_k.split(".lora_B.")[0], {})["B"] = _v
|
| 1685 |
+
_loaded = []
|
| 1686 |
+
for _base, _pair in _pairs.items():
|
| 1687 |
+
if "A" not in _pair or "B" not in _pair:
|
| 1688 |
+
continue
|
| 1689 |
+
_parts = _base.split(".")
|
| 1690 |
+
_obj = PIPE.transformer
|
| 1691 |
+
for _p in _parts:
|
| 1692 |
+
_obj = _obj[int(_p)] if _p.isdigit() else getattr(_obj, _p)
|
| 1693 |
+
_loaded.append((_obj, _pair["A"].to(_obj.weight.dtype), _pair["B"].to(_obj.weight.dtype)))
|
| 1694 |
+
if not _loaded:
|
| 1695 |
+
raise gr.Error(f"`{fname}` converted to no usable modules.")
|
| 1696 |
+
del _sd, _pairs, _raw
|
| 1697 |
+
import gc as _gc; _gc.collect()
|
| 1698 |
+
_CUSTOM_LoRA[key] = _loaded
|
| 1699 |
+
_log(f"cached LoRA {fname} loaded ({len(_loaded)} modules in {time.time() - _t0:.1f}s)")
|
| 1700 |
+
return key
|
| 1701 |
+
|
| 1702 |
+
|
| 1703 |
def _submit_community_lora(url, scale, trigger, description, tags):
|
| 1704 |
"""Add a LoRA to the community registry and return updated dropdown choices + status."""
|
| 1705 |
import json
|
|
|
|
| 1737 |
|
| 1738 |
|
| 1739 |
def _load_selected_community_lora(evt: gr.SelectData):
|
| 1740 |
+
"""When a community LoRA is selected from the dropdown, return its URL / scale / trigger.
|
| 1741 |
+
|
| 1742 |
+
For cached LoRAs (value starts with ``cache:``) the URL field is set to the
|
| 1743 |
+
cache path so the Download & Apply button can detect it."""
|
| 1744 |
registry = _load_community_registry()
|
| 1745 |
url = evt.value
|
| 1746 |
+
# Cached LoRAs use cache:path — pass through so _download_custom_lora picks them up.
|
| 1747 |
+
if url.startswith("cache:"):
|
| 1748 |
+
return url, 1.0, ""
|
| 1749 |
for entry in registry:
|
| 1750 |
e_url = entry.get("url", "")
|
| 1751 |
if e_url == url or f"https://huggingface.co/{entry.get('repo', '')}/resolve/main/{entry.get('file', '')}" == url:
|
|
|
|
| 2113 |
# User-supplied LoRA from the custom URL box: parse the link and require it was downloaded & verified.
|
| 2114 |
custom = []
|
| 2115 |
if custom_lora_url and str(custom_lora_url).strip():
|
| 2116 |
+
_url = str(custom_lora_url).strip()
|
| 2117 |
+
# Handle cache: prefix — LoRA was loaded directly from bucket cache.
|
| 2118 |
+
if _url.startswith("cache:"):
|
| 2119 |
+
_cache_path = _url[len("cache:"):]
|
| 2120 |
+
_key = ("__cache__", os.path.basename(_cache_path))
|
| 2121 |
+
if _key not in _CUSTOM_LoRA:
|
| 2122 |
+
raise gr.Error(f"Cached LoRA isn't loaded yet — press 'Download & Verify' first.")
|
| 2123 |
+
try:
|
| 2124 |
+
_scale = float(custom_lora_scale) if custom_lora_scale not in (None, "") else 1.0
|
| 2125 |
+
except (TypeError, ValueError):
|
| 2126 |
+
raise gr.Error("Custom LoRA scale must be a number.")
|
| 2127 |
+
if not 0.01 <= _scale <= 4.0:
|
| 2128 |
+
raise gr.Error("Custom LoRA scale must be between 0.01 and 4.")
|
| 2129 |
+
_trigger = str(custom_lora_trigger or "").strip()
|
| 2130 |
+
custom = [(_key[0], _key[1], _scale, _trigger)]
|
| 2131 |
+
else:
|
| 2132 |
+
_repo, _file = _parse_hf_link(_url)
|
| 2133 |
+
if _file is None:
|
| 2134 |
+
_file = _resolve_repo_file(_repo)
|
| 2135 |
+
if (_repo, _file) not in _CUSTOM_LoRA:
|
| 2136 |
+
raise gr.Error(f"`{_repo}/{_file}` isn't downloaded yet — press 'Download & Verify' first.")
|
| 2137 |
+
try:
|
| 2138 |
+
_scale = float(custom_lora_scale) if custom_lora_scale not in (None, "") else 1.0
|
| 2139 |
+
except (TypeError, ValueError):
|
| 2140 |
+
raise gr.Error("Custom LoRA scale must be a number.")
|
| 2141 |
+
if not 0.01 <= _scale <= 4.0:
|
| 2142 |
+
raise gr.Error("Custom LoRA scale must be between 0.01 and 4.")
|
| 2143 |
+
_trigger = str(custom_lora_trigger or "").strip()
|
| 2144 |
+
custom = [(_repo, _file, _scale, _trigger)]
|
| 2145 |
|
| 2146 |
if nsfw_keys or custom:
|
| 2147 |
nsfw_keys += ("nsfw_aio",)
|