File size: 17,199 Bytes
5a4f2a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
005fad1
 
 
5a4f2a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
005fad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a4f2a0
005fad1
 
 
 
 
 
5a4f2a0
 
 
 
 
 
 
 
 
005fad1
 
 
 
 
 
 
 
 
5a4f2a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
005fad1
 
 
 
 
 
 
 
 
5a4f2a0
 
 
 
005fad1
 
 
 
 
 
5a4f2a0
 
 
005fad1
 
 
 
 
 
 
 
 
5a4f2a0
005fad1
5a4f2a0
 
005fad1
 
 
 
 
 
 
5a4f2a0
 
 
 
 
 
 
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
"""The LoRA stack: a catalog, arbitrary URLs, and the adapters they turn into on the transformer.

diffusers carries the MiniMax-H3 LoRA loader itself (`MiniMaxH3LoraLoaderMixin`), including the conversion of the
ComfyUI / musubi layouts onto `MiniMaxH3Transformer3DModel`'s module names, so a slot is one `load_lora_weights`
call and the whole stack is one `set_adapters`. Alpha-less files load at `alpha == rank`; a `__metadata__` alpha is
honored instead. Weights are per slot and change for free - nothing is folded into the checkpoint.

A slot resolves in the main process (`LoraSpec.fetch`, which downloads) and is applied inside the GPU worker
(`apply`), so no file is ever pulled off the network on booked GPU time.
"""

from __future__ import annotations

import os
import re
import tempfile
import urllib.request
from dataclasses import dataclass, replace

NONE_CHOICE = "None"
CUSTOM_CHOICE = "Custom URL"

CACHE_DIR = os.path.join(tempfile.gettempdir(), "h3-lora-cache")

# How many adapters may stay resident on the card before the ones outside the current stack are dropped.
MAX_RESIDENT = int(os.getenv("H3_MAX_ADAPTERS", "8"))
# Identical requests in a row before the rack is folded into the weights. 1 folds immediately, 0 never folds — the
# fold makes every later request with that rack free, and makes the next change to it cost one unfold plus one fold.
FUSE_AFTER = int(os.getenv("H3_FUSE_AFTER", "2"))

# Every entry was checked against the released `transformer/` partition. LoRAs trained on a *pruned* checkpoint
# (joyfox/MiniMax-H3-Turbo is the known one) fail with a size mismatch and are deliberately not listed; the same
# repositories' `*_full*` files are, where they exist.
CATALOG: dict[str, dict] = {
    "Turbo 4-step · larryvrh": {
        "repo": "larryvrh/MiniMax-H3-Turbo-Lora",
        "weights": "minimax_h3_turbo_4step_ema_ckpt850.safetensors",
        "adapter": "turbo_larry",
        "default_weight": 1.0,
        "suggested_steps": 6,
        "url": "https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora",
        "about": "4-step distillation of the joint video+audio path. The usual starting point.",
        "hint": "Drop steps to 4-6. Above ~1.2 it sharpens into artifacts, below ~0.8 it goes soft.",
    },
    "Turbo 4-step · lightx2v": {
        "repo": "lightx2v/Minimax-h3-Turbo",
        "weights": "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors",
        "adapter": "turbo_lightx_4",
        "default_weight": 1.0,
        "suggested_steps": 5,
        "url": "https://huggingface.co/lightx2v/Minimax-h3-Turbo",
        "about": "Alternative 4-step distillation, trained at 768p on the first/last-frame branch.",
        "hint": "Trained for keyframed requests; holds up on text-only ones too.",
    },
    "Turbo 8-step · lightx2v": {
        "repo": "lightx2v/Minimax-h3-Turbo",
        "weights": "minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors",
        "adapter": "turbo_lightx_8",
        "default_weight": 1.0,
        "suggested_steps": 8,
        "url": "https://huggingface.co/lightx2v/Minimax-h3-Turbo",
        "about": "8-step sibling: slower than the 4-step files, steadier on motion.",
        "hint": "Give it 8 steps - it is not distilled down to 4.",
    },
    "Turbo 4-step · InstantX": {
        "repo": "InstantX/MiniMax-H3-Turbo-Lora-Diffusers",
        "weights": "minimax_h3_turbo_4step_ema_ckpt500_diffusers.safetensors",
        "adapter": "turbo_instantx",
        "default_weight": 1.0,
        "suggested_steps": 5,
        "url": "https://huggingface.co/InstantX/MiniMax-H3-Turbo-Lora-Diffusers",
        "about": "The ckpt500 EMA turbo LoRA, already converted to diffusers module names.",
        "hint": "An earlier checkpoint of the larryvrh round - useful for A/B against it.",
    },
    "Realism · People": {
        "repo": "fal/MiniMax-H3-Realism-People-LoRA",
        "weights": "h3-realism-people-t2v-i2v-r2v.safetensors",
        "adapter": "realism_people",
        "default_weight": 0.8,
        "url": "https://huggingface.co/fal/MiniMax-H3-Realism-People-LoRA",
        "about": "Photographic skin, hair and lighting on human subjects.",
        "hint": "Stacks well under a turbo LoRA. Back off toward 0.6 if faces start looking waxy.",
    },
    "Motion Adapter": {
        "repo": "MATLOWAI/MiniMax-H3-Motion-Adapter",
        "weights": "minimax_h3_motion_adapter_pilot_r16.safetensors",
        "adapter": "motion_adapter",
        "default_weight": 0.8,
        "url": "https://huggingface.co/MATLOWAI/MiniMax-H3-Motion-Adapter",
        "about": "Pushes the amount of movement in the shot up.",
        "hint": "Rank 16 pilot - subtle by design. Raise it before you blame the prompt.",
    },
    "Spatial Physics": {
        "repo": "Jojocodex/minimax-h3-spatial-physics-lora",
        "weights": "wushu_spatial_physics_v2_full.safetensors",
        "adapter": "spatial_physics",
        "default_weight": 0.8,
        "url": "https://huggingface.co/Jojocodex/minimax-h3-spatial-physics-lora",
        "about": "Steadier object motion, contact and weight.",
        "hint": "Pairs with Wushu Action for fight choreography that keeps its footing.",
    },
    "Wushu Action": {
        "repo": "Jojocodex/minimax-h3-wushu-action-lora",
        "weights": "wushu_action_h3_lora_v4_2000_full.safetensors",
        "adapter": "wushu_action",
        "default_weight": 0.8,
        "url": "https://huggingface.co/Jojocodex/minimax-h3-wushu-action-lora",
        "about": "Martial-arts choreography: strikes, spins, weapon work.",
        "hint": "Name the move in the prompt ('spinning hook kick'), not just the style.",
    },
    "Lineart Anime": {
        "repo": "DiffSynth-Studio/MiniMax-H3-LoRA-LineartAnime",
        "weights": "model.safetensors",
        "adapter": "lineart_anime",
        "default_weight": 1.0,
        "url": "https://huggingface.co/DiffSynth-Studio/MiniMax-H3-LoRA-LineartAnime",
        "about": "Flat-shaded lineart anime, trained against the raw fused-QKV checkpoint.",
        "hint": "Its factors are fp32; the transformer is cast back to bf16 after loading.",
    },
}

CHOICES = [NONE_CHOICE, *CATALOG, CUSTOM_CHOICE]


def default_weight(key: str) -> float:
    return CATALOG.get(key, {}).get("default_weight", 1.0)


def suggested_steps(keys) -> int | None:
    """The step count the fastest turbo LoRA in a stack was distilled for, if any."""
    steps = [CATALOG[key]["suggested_steps"] for key in keys if key in CATALOG and "suggested_steps" in CATALOG[key]]
    return min(steps) if steps else None


def about(key: str) -> tuple[str, str, str]:
    """`(about, hint, url)` of a catalog entry, empty strings for anything else."""
    entry = CATALOG.get(key, {})
    return entry.get("about", ""), entry.get("hint", ""), entry.get("url", "")


# ---------------------------------------------------------------------------
# Sources
# ---------------------------------------------------------------------------
def parse_source(raw: str) -> tuple[str, str, str | None]:
    """Any LoRA reference into `(kind, source, weight_name)`, `kind` being hf / civitai / direct."""
    raw = raw.strip()

    match = re.match(r"https?://huggingface\.co/([^/]+/[^/?#]+)(?:/(?:blob|resolve)/[^/]+/([^?#]+))?", raw)
    if match:
        return "hf", match.group(1), match.group(2)
    if "civitai.com" in raw:
        return "civitai", raw, None
    if raw.startswith("http"):
        return "direct", raw, None
    # `user/repo` or `user/repo/inside/file.safetensors`
    if re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]", raw):
        owner, name, *rest = raw.split("/", 2)
        return "hf", f"{owner}/{name}", rest[0] if rest else None
    raise ValueError(f"Not a LoRA source: {raw!r}")


def _civitai_download_url(url: str) -> str:
    """Any CivitAI URL into a direct download URL."""
    if "/api/download/models/" in url:
        return url
    match = re.search(r"[?&]modelVersionId=(\d+)", url)
    if not match:
        raise ValueError(
            "A CivitAI URL needs its modelVersionId, e.g. https://civitai.com/models/123?modelVersionId=456"
        )
    token = os.getenv("CIVITAI_API_KEY", "")
    url = f"https://civitai.com/api/download/models/{match.group(1)}"
    return f"{url}?token={token}" if token else url


def _download(url: str) -> str:
    """Fetch a LoRA file to the local cache once, and return its path."""
    os.makedirs(CACHE_DIR, exist_ok=True)
    name = url.split("?")[0].rstrip("/").split("/")[-1]
    if not name.endswith((".safetensors", ".sft")):
        name = re.sub(r"[^A-Za-z0-9]", "_", url)[-64:] + ".safetensors"
    path = os.path.join(CACHE_DIR, name)
    if not os.path.exists(path):
        request = urllib.request.Request(url, headers={"User-Agent": "minimax-h3-turbo-loras"})
        with urllib.request.urlopen(request, timeout=300) as response, open(path + ".part", "wb") as handle:
            while chunk := response.read(1 << 22):
                handle.write(chunk)
        os.replace(path + ".part", path)
    return path


@dataclass(frozen=True)
class LoraSpec:
    """One filled slot, on its way to the GPU worker. Picklable on purpose - it crosses a process boundary."""

    label: str
    weight: float
    adapter: str
    source: str  # a hub id, or a URL
    weights_file: str | None = None
    path: str | None = None  # filled by `fetch`

    def fetch(self) -> "LoraSpec":
        """Put the file on local disk. Runs in the main process, off booked GPU time."""
        if self.path:
            return self
        kind, source, weight_name = parse_source(self.source)
        if kind == "hf":
            from huggingface_hub import hf_hub_download

            name = self.weights_file or weight_name
            if name is None:
                name = _only_safetensors(source)
            return replace(self, path=hf_hub_download(source, name), weights_file=name)
        url = _civitai_download_url(source) if kind == "civitai" else source
        return replace(self, path=_download(url))


def _only_safetensors(repo: str) -> str:
    """The single `.safetensors` in a repo, when the user did not name one."""
    from huggingface_hub import list_repo_files

    files = [f for f in list_repo_files(repo) if f.endswith(".safetensors")]
    if not files:
        raise ValueError(f"`{repo}` holds no .safetensors file.")
    if len(files) > 1:
        raise ValueError(f"`{repo}` holds {len(files)} .safetensors files - name one, e.g. {repo}/{files[0]}")
    return files[0]


def slot_spec(key: str, url: str, weight: float) -> LoraSpec | None:
    """A UI slot into a spec, or None when the slot is empty."""
    if not key or key == NONE_CHOICE:
        return None
    if key == CUSTOM_CHOICE:
        url = (url or "").strip()
        if not url:
            return None
        parse_source(url)  # fail here, where the slot is still nameable
        return LoraSpec(
            label=url,
            weight=float(weight),
            adapter="custom_" + re.sub(r"[^A-Za-z0-9]", "_", url)[-40:],
            source=url,
        )
    entry = CATALOG[key]
    return LoraSpec(
        label=key,
        weight=float(weight),
        adapter=entry["adapter"],
        source=entry["repo"],
        weights_file=entry["weights"],
    )


def build_stack(slots) -> list[LoraSpec]:
    """`[(key, url, weight), ...]` into the specs to apply, first occurrence of an adapter winning."""
    stack: list[LoraSpec] = []
    seen: set[str] = set()
    for key, url, weight in slots:
        spec = slot_spec(key, url, weight)
        if spec is None or spec.adapter in seen:
            continue
        seen.add(spec.adapter)
        stack.append(spec)
    return stack


# ---------------------------------------------------------------------------
# Application - inside the GPU worker
# ---------------------------------------------------------------------------
def _resident(pipe) -> set[str]:
    """Adapters already injected into the transformer. The model is the source of truth: a fresh worker has none."""
    return set(getattr(pipe.transformer, "peft_config", {}) or {})


def stack_key(specs) -> tuple:
    """What makes two racks the same rack: the adapters and the weights they run at."""
    return tuple(sorted((spec.adapter, round(float(spec.weight), 4)) for spec in specs))


def _state(pipe) -> dict:
    """The fold bookkeeping, kept on the transformer itself.

    A recycled ZeroGPU worker re-forks the transformer from the startup copy, whose weights carry no fold and whose
    `peft_config` is empty — and with the bookkeeping on that same object, the state re-derives instead of lying.
    """
    transformer = pipe.transformer
    if not hasattr(transformer, "_stack_state"):
        transformer._stack_state = {"fused": None, "key": None, "repeats": 0}
    return transformer._stack_state


def apply(pipe, specs) -> str:
    """Load whatever is missing, activate the rack with its weights, fold it once it settles, and report.

    Unfused adapters cost an extra pair of matmuls on every linear layer of every step, which is the price of a
    weight slider that moves for free. So the rack is left unfused while it is being tuned, and folded into the
    bfloat16 weights once the same rack comes back `FUSE_AFTER` requests running: the fold is `weight * (B @ A)`
    added to the weights once, and every later request with that same rack pays nothing at all for its LoRAs.

    A slot that fails to load is reported and skipped - one unusable URL does not cost the whole request.
    """
    import torch

    active: list[str] = []
    weights: list[float] = []
    notes: list[str] = []

    state = _state(pipe)
    requested = stack_key(specs)
    # A fold that no longer matches the request comes out of the weights before anything else touches them - in
    # particular before a new adapter is injected into layers that still carry a merged one.
    if state["fused"] is not None and state["fused"] != requested:
        print(f"[lora] unfolding the previous rack ({len(state['fused'])} adapter(s))", flush=True)
        pipe.unfuse_lora()
        state["fused"] = None

    for spec in specs:
        try:
            if spec.path is None:
                raise ValueError("this slot was never fetched")
            if spec.adapter not in _resident(pipe):
                directory, filename = os.path.split(spec.path)
                pipe.load_lora_weights(directory, weight_name=filename, adapter_name=spec.adapter)
                # DiffSynth-Studio's fp32 factors would otherwise drag the unfused LoRA path into fp32.
                pipe.transformer.to(torch.bfloat16)
                print(f"[lora] loaded {spec.label} as `{spec.adapter}`", flush=True)
            active.append(spec.adapter)
            weights.append(float(spec.weight))
        except Exception as error:
            print(f"[lora] {spec.label} failed: {type(error).__name__}: {error}", flush=True)
            notes.append(f"{spec.label} failed ({type(error).__name__})")

    # The marker names what actually made it onto the card, which is the requested rack unless a slot failed.
    key = stack_key(spec for spec in specs if spec.adapter in active)
    if state["fused"] is not None and state["fused"] != key:
        pipe.unfuse_lora()
        state["fused"] = None

    state["repeats"] = state["repeats"] + 1 if key == state["key"] else 1
    state["key"] = key

    if not active:
        pipe.disable_lora()
        return "; ".join(["none", *notes]) if notes else "none"

    if state["fused"] == key:
        # The weights already carry this rack. Nothing to activate, nothing to pay.
        applied = ", ".join(f"{spec.label} @ {spec.weight:g}" for spec in specs if spec.adapter in active)
        return "; ".join([f"{applied} (folded)", *notes])

    _evict(pipe, keep=set(active), state=state)
    pipe.set_adapters(active, adapter_weights=weights)
    pipe.enable_lora()

    folded = ""
    if FUSE_AFTER and state["repeats"] >= FUSE_AFTER:
        # `set_adapters` above put each slot's weight on its adapter, and PEFT merges with exactly that scaling —
        # so the pipeline-level scale stays at 1.0. Merged adapters are skipped by the LoRA path, not applied twice.
        print(f"[lora] folding {len(active)} adapter(s) into the weights", flush=True)
        pipe.fuse_lora(adapter_names=active, lora_scale=1.0)
        state["fused"] = key
        folded = " (folded)"

    applied = ", ".join(f"{spec.label} @ {spec.weight:g}" for spec in specs if spec.adapter in active)
    return "; ".join([applied + folded, *notes])


def _evict(pipe, keep: set[str], state: dict) -> None:
    """Drop resident adapters outside the current rack once too many have piled up on the card.

    Never while a fold is standing: deleting an adapter whose delta sits in the weights would strand it there.
    """
    if state["fused"] is not None:
        return
    resident = _resident(pipe)
    if len(resident) <= MAX_RESIDENT:
        return
    stale = sorted(resident - keep)
    if stale:
        print(f"[lora] evicting {len(stale)} adapter(s): {', '.join(stale)}", flush=True)
        pipe.delete_adapters(stale)