File size: 2,963 Bytes
b66c0ec
 
 
 
 
 
 
 
 
067cbe6
b66c0ec
 
 
067cbe6
b66c0ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
067cbe6
b66c0ec
067cbe6
b66c0ec
 
067cbe6
 
 
 
 
 
 
b66c0ec
067cbe6
 
 
 
 
b66c0ec
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
"""ComfyUI custom node: load a LoRA fetched from a URL at request time.

Lets serverless workflows reference LoRAs by URL (HF, civitai, anywhere)
instead of requiring the file to be pre-baked into the image. Downloads are
cached on the worker's disk keyed by URL hash. Supports HF_TOKEN and
CIVITAI_TOKEN env vars for authenticated downloads.
"""

import hashlib
import logging
import os
import urllib.request

import comfy.lora
import comfy.sd
import comfy.utils

CACHE_DIR = "/lora-cache"
os.makedirs(CACHE_DIR, exist_ok=True)


def _fetch(url: str) -> str:
    path = os.path.join(CACHE_DIR, hashlib.sha1(url.encode()).hexdigest() + ".safetensors")
    if os.path.exists(path):
        return path
    headers = {}
    hf_token = os.environ.get("HF_TOKEN")
    if hf_token and "huggingface.co" in url:
        headers["Authorization"] = f"Bearer {hf_token}"
    civitai_token = os.environ.get("CIVITAI_TOKEN")
    if civitai_token and "civitai" in url:
        url += ("&" if "?" in url else "?") + "token=" + civitai_token
    req = urllib.request.Request(url, headers=headers)
    tmp = path + ".part"
    with urllib.request.urlopen(req, timeout=600) as r, open(tmp, "wb") as f:
        while chunk := r.read(1 << 20):
            f.write(chunk)
    os.rename(tmp, path)
    return path


class LoraFromURLModelOnly:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "model": ("MODEL",),
                "url": ("STRING", {"default": ""}),
                "strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
            }
        }

    RETURN_TYPES = ("MODEL",)
    FUNCTION = "load"
    CATEGORY = "loaders"

    def load(self, model, url, strength):
        url = (url or "").strip()
        name = url.rsplit("/", 1)[-1] or "lora"
        if not url or strength == 0:
            logging.info(f"[lora_from_url] {name}: skipped (empty url / strength 0)")
            return (model,)
        lora_sd = comfy.utils.load_torch_file(_fetch(url), safe_load=True)
        total = len(lora_sd)
        # How many lora keys actually map onto this model's weights — the
        # definitive "did it bind" signal. load_lora consumes matched keys and
        # returns the leftovers it could not place.
        key_map = comfy.lora.model_lora_keys_unet(model.model, {})
        loaded = comfy.lora.load_lora(lora_sd, key_map)
        matched = len(loaded)
        model_lora, _ = comfy.sd.load_lora_for_models(model, None, lora_sd, strength, 0)
        logging.info(
            f"[lora_from_url] {name}: matched {matched} target modules "
            f"from {total} tensors at strength {strength}"
            + ("  ⚠ ZERO MATCHES — lora did NOT bind" if matched == 0 else "")
        )
        return (model_lora,)


NODE_CLASS_MAPPINGS = {"LoraFromURLModelOnly": LoraFromURLModelOnly}
NODE_DISPLAY_NAME_MAPPINGS = {"LoraFromURLModelOnly": "Load LoRA from URL (model only)"}