File size: 31,592 Bytes
e8b6587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
"""Ordinary user-LoRA preparation and request-scoped adapter lifecycle.

This module owns backend LoRA policy only. Gradio widgets and event wiring remain in
``app.py``. The internal Full/SFT Stage-2 adapter and IC-Colorizer adapter remain
separate domains; this module deliberately does not generalize them into one manager.
"""
from __future__ import annotations

import gc
import time
import urllib.parse
from pathlib import Path
from typing import Callable

import torch
from huggingface_hub import HfApi, hf_hub_download, parse_hf_uri
from huggingface_hub.errors import (
    GatedRepoError,
    HfHubHTTPError,
    LocalEntryNotFoundError,
    RemoteEntryNotFoundError,
    RepositoryNotFoundError,
    RevisionNotFoundError,
)
from safetensors import safe_open
from safetensors.torch import load_file as load_safetensors_file, save_file as save_safetensors_file

from ltx import civitai as civitai_backend
from ltx.runtime_utils import resolved_revision_from_hub_path


class LoraSelectionError(ValueError):
    """User-facing ordinary LoRA selection/preparation error."""


def sanitize_defs(items, source: str = "configured") -> list[dict]:
    """Normalize portable LoRA definitions without persisting transport secrets."""
    clean: list[dict] = []
    seen: set[str] = set()
    for raw in items or []:
        if not isinstance(raw, dict):
            continue
        label = str(raw.get("label") or "").strip()
        weight_name = str(raw.get("weight_name") or "").strip()
        item_source = str(raw.get("source") or source).strip() or source
        if not label or not weight_name or label in seen:
            continue
        if item_source == "civitai":
            try:
                model_id = int(raw.get("civitai_model_id"))
                version_id = int(raw.get("civitai_version_id"))
                file_id = int(raw.get("civitai_file_id"))
            except Exception:
                continue
            clean.append(
                {
                    "label": label,
                    "source": "civitai",
                    "repo_id": "",
                    "weight_name": weight_name,
                    "revision": "",
                    "civitai_model_id": model_id,
                    "civitai_version_id": version_id,
                    "civitai_file_id": file_id,
                    "civitai_sha256": str(raw.get("civitai_sha256") or "").strip().lower(),
                    "civitai_page_url": str(raw.get("civitai_page_url") or "").strip(),
                    "trained_words": [str(x) for x in (raw.get("trained_words") or []) if str(x).strip()],
                }
            )
            seen.add(label)
            continue
        repo_id = str(raw.get("repo_id") or "").strip()
        revision = str(raw.get("revision") or "").strip()
        if repo_id:
            clean.append(
                {
                    "label": label,
                    "repo_id": repo_id,
                    "weight_name": weight_name,
                    "revision": revision,
                    "source": item_source,
                }
            )
            seen.add(label)
    return clean


def all_defs(builtin_loras, custom_loras=None) -> list[dict]:
    builtins = sanitize_defs(builtin_loras, source="built_in")
    sessions = sanitize_defs(custom_loras, source="session")
    builtin_labels = {item["label"] for item in builtins}
    sessions = [item for item in sessions if item["label"] not in builtin_labels]
    return builtins + sessions


def validate_repo_id(repo_id: str) -> str:
    repo_id = str(repo_id or "").strip()
    if repo_id.count("/") != 1 or repo_id.startswith("/") or repo_id.endswith("/"):
        raise LoraSelectionError("HF repo ID must look like owner/repo.")
    return repo_id


def validate_weight_name(weight_name: str) -> str:
    weight_name = str(weight_name or "").strip().replace("\\", "/")
    if not weight_name.endswith(".safetensors"):
        raise LoraSelectionError("LoRA weight filename must end with .safetensors.")
    if weight_name.startswith("/") or ".." in Path(weight_name).parts:
        raise LoraSelectionError("LoRA weight filename must be a repository-relative path.")
    return weight_name


def normalize_hf_lora_source(source: str, revision: str = "", weight_name: str = "") -> dict:
    """Canonicalize a Hub repo ID, model URL, file URL, or hf:// model URI."""
    raw = str(source or "").strip()
    if not raw:
        raise LoraSelectionError("Enter an HF model repo ID or URL.")
    revision = str(revision or "").strip()
    weight_name = str(weight_name or "").strip().replace("\\", "/")

    url_revision = ""
    url_weight = ""
    source_kind = "repo_id"
    is_hf_uri = raw.startswith("hf://")
    is_hf_web = "://" in raw or raw.startswith(("huggingface.co/", "www.huggingface.co/", "hf.co/"))

    if is_hf_uri:
        source_kind = "hf_uri"
        try:
            uri = parse_hf_uri(raw)
        except Exception as exc:
            raise LoraSelectionError("Invalid Hugging Face hf:// URI.") from exc
        if uri.type != "model":
            raise LoraSelectionError("HF LoRA source must point to a model repository, not a dataset/Space/kernel/bucket.")
        repo_id = validate_repo_id(uri.id)
        url_revision = str(uri.revision or "").strip()
        url_weight = str(uri.path_in_repo or "").strip().replace("\\", "/")
    elif not is_hf_web:
        repo_id = validate_repo_id(raw)
    else:
        source_kind = "url"
        web_raw = raw if "://" in raw else f"https://{raw}"
        # Newer huggingface_hub releases parse supported web URLs directly. Keep
        # the small fallback below so the product remains tolerant of older
        # environments while requirements/runtime converge.
        uri = None
        try:
            uri = parse_hf_uri(web_raw)
        except Exception:
            uri = None
        if uri is not None:
            if uri.type != "model":
                raise LoraSelectionError("HF LoRA source must point to a model repository, not a dataset/Space/kernel/bucket.")
            repo_id = validate_repo_id(uri.id)
            url_revision = str(uri.revision or "").strip()
            url_weight = str(uri.path_in_repo or "").strip().replace("\\", "/")
        else:
            try:
                parsed = urllib.parse.urlsplit(web_raw)
            except Exception as exc:
                raise LoraSelectionError("Invalid Hugging Face URL.") from exc
            if (parsed.hostname or "").casefold() not in {"huggingface.co", "www.huggingface.co", "hf.co"}:
                raise LoraSelectionError("HF LoRA URL must point to huggingface.co or hf.co.")
            raw_parts = [x for x in (parsed.path or "").strip("/").split("/") if x]
            parts = [urllib.parse.unquote(x) for x in raw_parts]
            if parts and parts[0] == "models":
                parts = parts[1:]
            if parts and parts[0] in {"datasets", "spaces", "kernels", "buckets", "collections"}:
                raise LoraSelectionError("HF LoRA source must point to a model repository.")
            if len(parts) < 2:
                raise LoraSelectionError("HF model URL must include owner/repo.")
            repo_id = validate_repo_id(f"{parts[0]}/{parts[1]}")
            if len(parts) > 2:
                route = parts[2]
                if route in {"blob", "resolve", "raw"}:
                    rest = parts[3:]
                    if len(rest) < 2:
                        raise LoraSelectionError("HF file URL must include revision and repository-relative file path.")
                    if len(rest) >= 4 and rest[0] == "refs" and rest[1] in {"pr", "convert"}:
                        url_revision = "/".join(rest[:3])
                        url_weight = "/".join(rest[3:])
                    else:
                        url_revision = rest[0]
                        url_weight = "/".join(rest[1:])
                elif route == "tree":
                    rest = parts[3:]
                    if not rest:
                        raise LoraSelectionError("HF tree URL must include a revision.")
                    if len(rest) >= 3 and rest[0] == "refs" and rest[1] in {"pr", "convert"}:
                        url_revision = "/".join(rest[:3])
                    else:
                        url_revision = rest[0]
                else:
                    raise LoraSelectionError("Unsupported Hugging Face model URL route. Use the repo page or a blob/resolve/raw file URL.")

    if url_revision and revision and url_revision != revision:
        raise LoraSelectionError(f"HF URL revision {url_revision!r} conflicts with Revision {revision!r}.")
    resolved_revision = url_revision or revision
    if url_weight:
        url_weight = validate_weight_name(url_weight)
        if weight_name and validate_weight_name(weight_name) != url_weight:
            raise LoraSelectionError("HF file URL conflicts with the Safetensors file field.")
        resolved_weight = url_weight
    else:
        resolved_weight = validate_weight_name(weight_name) if weight_name else ""
    return {
        "repo_id": repo_id,
        "revision": resolved_revision,
        "weight_name": resolved_weight,
        "source_kind": source_kind,
    }


def _download_hf_lora_file(item: dict, hf_token: str | None) -> Path:
    """Download one exact Hub LoRA file and normalize common Hub failures."""
    repo_id = str(item.get("repo_id") or "").strip()
    weight_name = str(item.get("weight_name") or "").strip()
    revision = str(item.get("revision") or "").strip() or None
    try:
        return Path(
            hf_hub_download(
                repo_id=repo_id,
                filename=weight_name,
                revision=revision,
                token=hf_token,
            )
        )
    except GatedRepoError as exc:
        raise LoraSelectionError(
            f"HF LoRA repo {repo_id} is gated and the current Space token cannot access it. "
            "Request access for the token owner or configure an authorized HF token."
        ) from exc
    except RepositoryNotFoundError as exc:
        raise LoraSelectionError(
            f"HF LoRA repo {repo_id} was not found or is private to the current Space token."
        ) from exc
    except RevisionNotFoundError as exc:
        raise LoraSelectionError(
            f"HF revision {revision!r} was not found in {repo_id}."
        ) from exc
    except RemoteEntryNotFoundError as exc:
        where = f" at revision {revision!r}" if revision else ""
        raise LoraSelectionError(
            f"HF LoRA file {weight_name!r} was not found in {repo_id}{where}. "
            "Inspect the repo and choose an exact .safetensors file."
        ) from exc
    except LocalEntryNotFoundError as exc:
        raise LoraSelectionError(
            f"HF LoRA file {weight_name!r} is not cached locally and the Hub could not be reached. "
            "Check network/offline mode and try again."
        ) from exc
    except HfHubHTTPError as exc:
        status = getattr(getattr(exc, "response", None), "status_code", None)
        if status in {401, 403}:
            raise LoraSelectionError(
                f"HF denied access while downloading {repo_id}/{weight_name} (HTTP {status}). "
                "Check gated/private access and the Space HF token."
            ) from exc
        if status == 429:
            raise LoraSelectionError(
                "HF Hub rate-limited the LoRA download (HTTP 429). Wait briefly and try again."
            ) from exc
        suffix = f" (HTTP {status})" if status else ""
        raise LoraSelectionError(
            f"HF LoRA download failed for {repo_id}/{weight_name}{suffix}."
        ) from exc
    except OSError as exc:
        raise LoraSelectionError(
            f"HF LoRA download could not complete for {repo_id}/{weight_name}: {type(exc).__name__}."
        ) from exc


def resolve_selected(selected, builtin_loras, custom_loras) -> list[dict]:
    defs = {item["label"]: item for item in all_defs(builtin_loras, custom_loras)}
    resolved = []
    for label in selected or []:
        if label not in defs:
            raise LoraSelectionError(f"Selected LoRA is unavailable: {label}")
        resolved.append(defs[label])
    return resolved


def inspect_model_repo(repo_id: str, hf_token: str | None, revision: str | None = None) -> dict:
    """CPU/network-only Hub metadata inspection for the session-LoRA form."""
    repo_id = validate_repo_id(repo_id)
    revision = str(revision or "").strip() or None
    try:
        info = HfApi(token=hf_token).model_info(repo_id=repo_id, revision=revision, files_metadata=False)
    except GatedRepoError as exc:
        raise LoraSelectionError(f"HF repo {repo_id} is gated and the current Space token cannot access it.") from exc
    except RepositoryNotFoundError as exc:
        raise LoraSelectionError(f"HF model repo {repo_id} was not found or is private to the current Space token.") from exc
    except RevisionNotFoundError as exc:
        raise LoraSelectionError(f"HF revision {revision!r} was not found in {repo_id}.") from exc
    except HfHubHTTPError as exc:
        status = getattr(getattr(exc, "response", None), "status_code", None)
        suffix = f" (HTTP {status})" if status else ""
        raise LoraSelectionError(f"HF repo inspection failed for {repo_id}{suffix}.") from exc
    except Exception as exc:
        raise LoraSelectionError(f"HF repo inspection failed for {repo_id}: {type(exc).__name__}.") from exc

    safetensors = sorted(
        {
            str(getattr(sibling, "rfilename", "") or "")
            for sibling in (getattr(info, "siblings", None) or [])
            if str(getattr(sibling, "rfilename", "") or "").lower().endswith(".safetensors")
        }
    )
    return {
        "repo_id": repo_id,
        "requested_revision": revision,
        "resolved_revision": str(getattr(info, "sha", "") or "") or None,
        "private": bool(getattr(info, "private", False)),
        "gated": getattr(info, "gated", None),
        "safetensors": safetensors,
        "tags": [str(x) for x in (getattr(info, "tags", None) or [])],
    }



_LTX2_LORA_PREFIXES = ("diffusion_model.", "text_embedding_projection.", "transformer.", "connectors.")


def _checkpoint_key_sample(keys, limit: int = 6) -> list[str]:
    return [str(key) for key in list(keys)[: max(1, int(limit))]]


def _mapped_lora_key(key: str) -> str:
    return str(key).replace(".lora_down.weight", ".lora_A.weight").replace(".lora_up.weight", ".lora_B.weight")


def _inspect_civitai_ltx2_checkpoint(local: Path) -> dict:
    """Inspect a Civitai safetensors header without loading tensor payloads."""
    try:
        with safe_open(str(local), framework="pt", device="cpu") as handle:
            keys = list(handle.keys())
    except Exception as exc:
        raise LoraSelectionError(f"Could not inspect Civitai LoRA safetensors header: {type(exc).__name__}: {exc}") from exc
    if not keys:
        raise LoraSelectionError("Civitai checkpoint is empty; refusing to request GPU quota.")

    unsupported_prefixes = [key for key in keys if not str(key).startswith(_LTX2_LORA_PREFIXES)]
    if unsupported_prefixes:
        sample = ", ".join(_checkpoint_key_sample(unsupported_prefixes))
        raise LoraSelectionError(
            "Unsupported LTX-2 LoRA checkpoint dialect from Civitai. "
            f"Unexpected parameter namespace(s): {sample}. Try another file/version. GPU quota was not requested."
        )

    alpha_keys = [key for key in keys if str(key).endswith(".alpha")]
    down_up_keys = [key for key in keys if ".lora_down.weight" in str(key) or ".lora_up.weight" in str(key)]
    unsupported_non_lora = [key for key in keys if "lora" not in str(key).lower() and not str(key).endswith(".alpha")]
    if unsupported_non_lora:
        sample = ", ".join(_checkpoint_key_sample(unsupported_non_lora))
        raise LoraSelectionError(
            "Civitai file is not a supported LTX-2 LoRA-only checkpoint. "
            f"Unexpected parameter(s): {sample}. Try another exact .safetensors file/version. GPU quota was not requested."
        )

    if alpha_keys or down_up_keys:
        dialect = "ltx2_comfy_alpha_or_down_up"
        needs_normalization = True
    elif any(str(key).startswith("diffusion_model.") or str(key).startswith("text_embedding_projection.") for key in keys):
        dialect = "ltx2_comfy_native"
        needs_normalization = False
    else:
        dialect = "ltx2_diffusers_peft"
        needs_normalization = False
    return {
        "dialect": dialect,
        "key_count": len(keys),
        "key_sample": _checkpoint_key_sample(keys),
        "alpha_key_count": len(alpha_keys),
        "down_up_key_count": len(down_up_keys),
        "needs_normalization": needs_normalization,
    }


def _normalize_civitai_ltx2_checkpoint(local: Path, cache_root: Path, source_sha256: str) -> tuple[Path, dict]:
    """Normalize only known LTX-2 Comfy LoRA variants before GPU allocation.

    Current pinned Diffusers accepts LTX2 Comfy-style ``diffusion_model.`` keys but
    does not normalize per-module ``.alpha`` or generic ``lora_down/lora_up`` keys.
    For that narrow, known dialect we rename down/up to A/B and fold alpha/rank
    into lora_B. Unknown or mixed checkpoint dialects fail closed.
    """
    evidence = _inspect_civitai_ltx2_checkpoint(local)
    if not evidence["needs_normalization"]:
        evidence.update(normalized=False, normalized_path=None, alpha_fold_count=0)
        return local, evidence

    try:
        state = load_safetensors_file(str(local), device="cpu")
    except Exception as exc:
        raise LoraSelectionError(f"Could not read Civitai LoRA tensors for CPU normalization: {type(exc).__name__}: {exc}") from exc

    normalized: dict[str, torch.Tensor] = {}
    alpha_tensors: dict[str, torch.Tensor] = {}
    for raw_key, tensor in state.items():
        key = str(raw_key)
        if key.endswith(".alpha"):
            alpha_tensors[key[:-len(".alpha")]] = tensor
            continue
        mapped = _mapped_lora_key(key)
        if mapped in normalized:
            raise LoraSelectionError(f"Checkpoint normalization produced a duplicate LoRA key: {mapped}")
        normalized[mapped] = tensor

    a_keys = [key for key in normalized if key.endswith(".lora_A.weight")]
    b_keys = [key for key in normalized if key.endswith(".lora_B.weight")]
    if not a_keys or not b_keys:
        raise LoraSelectionError(
            "Unsupported Civitai LoRA checkpoint: no complete lora_A/lora_B pairs were found after known-format normalization. "
            "Try another file/version. GPU quota was not requested."
        )

    pair_bases = set()
    for key in a_keys:
        base = key[:-len(".lora_A.weight")]
        b_key = base + ".lora_B.weight"
        if b_key not in normalized:
            raise LoraSelectionError(f"Incomplete Civitai LoRA pair for {base}: lora_B is missing. GPU quota was not requested.")
        pair_bases.add(base)
    for key in b_keys:
        base = key[:-len(".lora_B.weight")]
        if base + ".lora_A.weight" not in normalized:
            raise LoraSelectionError(f"Incomplete Civitai LoRA pair for {base}: lora_A is missing. GPU quota was not requested.")
        pair_bases.add(base)

    alpha_fold_count = 0
    for base, alpha_tensor in alpha_tensors.items():
        a_key = base + ".lora_A.weight"
        b_key = base + ".lora_B.weight"
        if a_key not in normalized or b_key not in normalized:
            raise LoraSelectionError(
                f"Civitai LoRA alpha has no matching A/B pair for {base}. Refusing unsafe fallback before GPU allocation."
            )
        if alpha_tensor.numel() != 1:
            raise LoraSelectionError(f"Civitai LoRA alpha for {base} is not scalar; unsupported checkpoint dialect.")
        a_tensor = normalized[a_key]
        if a_tensor.ndim < 1 or int(a_tensor.shape[0]) <= 0:
            raise LoraSelectionError(f"Could not derive LoRA rank for {base}; unsupported checkpoint dialect.")
        rank = int(a_tensor.shape[0])
        alpha = float(alpha_tensor.detach().float().item())
        scale = alpha / float(rank)
        original_b = normalized[b_key]
        normalized[b_key] = (original_b.detach().float() * scale).to(dtype=original_b.dtype)
        alpha_fold_count += 1

    remaining_bad = [key for key in normalized if "lora" not in key.lower()]
    if remaining_bad:
        sample = ", ".join(_checkpoint_key_sample(remaining_bad))
        raise LoraSelectionError(f"Unsupported non-LoRA parameter(s) remain after normalization: {sample}")

    source_sha256 = str(source_sha256 or "").strip().lower()
    if len(source_sha256) != 64:
        import hashlib
        digest = hashlib.sha256()
        with local.open("rb") as fh:
            for chunk in iter(lambda: fh.read(1024 * 1024), b""):
                digest.update(chunk)
        source_sha256 = digest.hexdigest()
    out_dir = Path(cache_root) / "normalized_ltx2_lora"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"{source_sha256}.diffusers-compatible.safetensors"
    try:
        save_safetensors_file(normalized, str(out_path))
    except Exception as exc:
        raise LoraSelectionError(f"Could not write runtime-normalized Civitai LoRA: {type(exc).__name__}: {exc}") from exc

    evidence.update(
        normalized=True,
        normalized_path=str(out_path),
        normalized_key_count=len(normalized),
        alpha_fold_count=alpha_fold_count,
        source_sha256=source_sha256,
    )
    return out_path, evidence


def definition_key(item: dict) -> tuple[str, ...]:
    source = str(item.get("source") or "").strip()
    if source == "civitai":
        return (
            "civitai",
            str(item.get("civitai_model_id") or ""),
            str(item.get("civitai_version_id") or ""),
            str(item.get("civitai_file_id") or ""),
            str(item.get("civitai_sha256") or "").lower(),
        )
    return (
        "hf",
        str(item.get("repo_id") or ""),
        str(item.get("weight_name") or ""),
        str(item.get("revision") or item.get("requested_revision") or ""),
    )


def prepare_selected(selected, builtin_loras, custom_loras, hf_token: str | None, civitai_cache_root: Path | None = None, civitai_api_key: str = "") -> tuple[list[dict], float]:
    """CPU-side source preparation. This function must never run inside a GPU callback."""
    resolved = resolve_selected(selected, builtin_loras, custom_loras)
    if not resolved:
        return [], 0.0
    prepared = []
    total_started = time.monotonic()
    for item in resolved:
        item_started = time.monotonic()
        if str(item.get("source") or "") == "civitai":
            if civitai_cache_root is None:
                raise LoraSelectionError("Civitai LoRA cache root is unavailable.")
            try:
                acquired = civitai_backend.download_exact_file(item, Path(civitai_cache_root), api_key=civitai_api_key)
            except civitai_backend.CivitaiError as exc:
                raise LoraSelectionError(str(exc)) from exc
            local = Path(str(acquired["local_path"]))
            source_sha256 = str(acquired.get("sha256") or item.get("civitai_sha256") or "").strip().lower()
            local, checkpoint_compatibility = _normalize_civitai_ltx2_checkpoint(
                local, Path(civitai_cache_root), source_sha256
            )
            record = {
                "label": item["label"],
                "source": "civitai",
                "repo_id": None,
                "weight_name": item["weight_name"],
                "requested_revision": None,
                "resolved_revision": None,
                "civitai_model_id": item.get("civitai_model_id"),
                "civitai_version_id": item.get("civitai_version_id"),
                "civitai_file_id": item.get("civitai_file_id"),
                "civitai_sha256": acquired.get("sha256") or item.get("civitai_sha256"),
                "local_path": str(local),
                "source_size_bytes": int(acquired.get("size_bytes") or 0),
                "size_bytes": int(local.stat().st_size),
                "checkpoint_compatibility": checkpoint_compatibility,
                "cache_hit": bool(acquired.get("cache_hit")),
                "prepare_seconds": time.monotonic() - item_started,
            }
        else:
            local = _download_hf_lora_file(item, hf_token)
            record = {
                "label": item["label"],
                "source": item.get("source"),
                "repo_id": item["repo_id"],
                "weight_name": item["weight_name"],
                "requested_revision": item["revision"] or None,
                "resolved_revision": resolved_revision_from_hub_path(local),
                "local_path": str(local),
                "size_bytes": int(local.stat().st_size),
                "prepare_seconds": time.monotonic() - item_started,
            }
        record["definition_key"] = list(definition_key(item))
        prepared.append(record)
    return prepared, time.monotonic() - total_started


def prepared_lookup(prepared_loras) -> dict:
    lookup = {}
    for item in prepared_loras or []:
        if not isinstance(item, dict):
            continue
        raw_key = item.get("definition_key")
        if isinstance(raw_key, (list, tuple)) and raw_key:
            key = tuple(str(x) for x in raw_key)
        else:
            key = definition_key(item)
        lookup[key] = item
    return lookup


def adapter_state(pipe) -> dict:
    state = {}
    for name in ("get_active_adapters", "get_list_adapters"):
        fn = getattr(pipe, name, None)
        if callable(fn):
            try:
                state[name] = fn()
            except Exception as exc:
                state[name] = {"error": f"{type(exc).__name__}: {exc}"}
    return state


def load_request_loras(
    *,
    pipe,
    pipe_i2v,
    pipe_condition,
    pipe_ic,
    selected,
    builtin_loras,
    custom_loras,
    prepared_loras,
    strength: float,
    request_id: str,
    gpu_state: Callable[[], dict],
) -> tuple[list[dict], dict]:
    metrics = {
        "requested_labels": [str(x) for x in (selected or [])],
        "requested_count": len(selected or []),
        "strength": float(strength),
        "hub_download_inside_gpu_callback": False,
        "request_scoped": True,
        "fused": False,
    }
    if not selected:
        metrics["status"] = "not_requested"
        return [], metrics
    resolved = resolve_selected(selected, builtin_loras, custom_loras)
    prepared = prepared_lookup(prepared_loras)
    loaded = []
    adapter_names = []
    metrics["gpu_before_load"] = gpu_state()
    total_started = time.monotonic()
    try:
        per_adapter = []
        for idx, item in enumerate(resolved):
            key = definition_key(item)
            prep = prepared.get(key)
            if not prep:
                raise LoraSelectionError(
                    f"Selected LoRA is not CPU-prepared: {item['label']}. "
                    "Use Prepare selected LoRAs; Generate normally performs this pre-step automatically."
                )
            local = Path(str(prep.get("local_path") or ""))
            if not local.is_file():
                raise LoraSelectionError(f"Prepared LoRA file is no longer available locally: {item['label']}")
            adapter_name = f"req_{request_id[:8]}_{idx}"
            load_started = time.monotonic()
            pipe.load_lora_weights(str(local.parent), weight_name=local.name, adapter_name=adapter_name)
            load_seconds = time.monotonic() - load_started
            adapter_names.append(adapter_name)
            public_record = {
                "label": item["label"],
                "source": item.get("source"),
                "repo_id": item.get("repo_id") or None,
                "weight_name": item["weight_name"],
                "requested_revision": item.get("revision") or None,
                "resolved_revision": prep.get("resolved_revision"),
                "size_bytes": prep.get("size_bytes"),
                "adapter_name": adapter_name,
                "load_seconds": load_seconds,
            }
            if item.get("source") == "civitai":
                public_record.update(
                    civitai_model_id=item.get("civitai_model_id"),
                    civitai_version_id=item.get("civitai_version_id"),
                    civitai_file_id=item.get("civitai_file_id"),
                    civitai_sha256=prep.get("civitai_sha256") or item.get("civitai_sha256"),
                    checkpoint_compatibility=prep.get("checkpoint_compatibility"),
                )
            loaded.append(public_record)
            per_adapter.append({k: public_record[k] for k in ("label", "adapter_name", "size_bytes", "load_seconds")})
        set_started = time.monotonic()
        pipe.set_adapters(adapter_names, adapter_weights=[float(strength)] * len(adapter_names))
        pipe.enable_lora()
        metrics["set_adapters_seconds"] = time.monotonic() - set_started
        metrics["load_total_seconds"] = time.monotonic() - total_started
        metrics["per_adapter"] = per_adapter
        metrics["adapter_names"] = list(adapter_names)
        metrics["adapter_state_after_set"] = adapter_state(pipe)
        metrics["gpu_after_load"] = gpu_state()
        metrics["shared_component_identity"] = {
            "pipe_i2v_transformer_shared": bool(pipe_i2v is None or pipe_i2v.transformer is pipe.transformer),
            "pipe_i2v_connectors_shared": bool(pipe_i2v is None or pipe_i2v.connectors is pipe.connectors),
            "pipe_condition_transformer_shared": bool(pipe_condition is None or pipe_condition.transformer is pipe.transformer),
            "pipe_condition_connectors_shared": bool(pipe_condition is None or pipe_condition.connectors is pipe.connectors),
            "pipe_ic_transformer_shared": bool(pipe_ic is None or pipe_ic.transformer is pipe.transformer),
            "pipe_ic_connectors_shared": bool(pipe_ic is None or pipe_ic.connectors is pipe.connectors),
        }
        metrics["status"] = "loaded_active"
        return loaded, metrics
    except Exception:
        try:
            pipe.disable_lora()
            if adapter_names:
                pipe.delete_adapters(adapter_names)
        except Exception:
            pass
        raise


def cleanup_request_loras(
    *,
    pipe,
    loaded,
    metrics=None,
    full_sft_profile: bool,
    gpu_state: Callable[[], dict],
) -> dict:
    metrics = metrics if isinstance(metrics, dict) else {}
    if not loaded:
        metrics.setdefault("cleanup_status", "not_needed")
        return metrics
    adapter_names = [str(item.get("adapter_name")) for item in loaded if item.get("adapter_name")]
    metrics["gpu_before_cleanup"] = gpu_state()
    metrics["cleanup_adapter_names"] = list(adapter_names)
    metrics["internal_stage2_adapter_preserved"] = bool(full_sft_profile)
    started = time.monotonic()
    try:
        pipe.disable_lora()
        if adapter_names:
            pipe.delete_adapters(adapter_names)
        metrics["cleanup_status"] = "PASS"
    finally:
        gc.collect()
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
        metrics["cleanup_seconds"] = time.monotonic() - started
        metrics["adapter_state_after_cleanup"] = adapter_state(pipe)
        metrics["gpu_after_cleanup"] = gpu_state()
    return metrics