saliacoel commited on
Commit
74b8775
·
verified ·
1 Parent(s): 836f12e

Upload wan_lora_nodes.py

Browse files
Files changed (1) hide show
  1. wan_lora_nodes.py +542 -0
wan_lora_nodes.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import re
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, Optional, Tuple
8
+
9
+ import torch
10
+ from safetensors import safe_open
11
+ from safetensors.torch import save_file
12
+
13
+
14
+ # ============================================================
15
+ # Helpers
16
+ # ============================================================
17
+
18
+
19
+ def _clean_path(path: str) -> str:
20
+ if path is None:
21
+ return ""
22
+ return str(path).strip().strip('"').strip("'")
23
+
24
+
25
+
26
+ def _ensure_metadata_str_dict(metadata: Optional[dict]) -> Dict[str, str]:
27
+ if not metadata:
28
+ return {}
29
+ return {str(k): str(v) for k, v in metadata.items()}
30
+
31
+
32
+
33
+ def _file_signature(path: str) -> str:
34
+ p = Path(path)
35
+ stat = p.stat()
36
+ payload = f"{p.resolve()}|{stat.st_size}|{stat.st_mtime_ns}"
37
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
38
+
39
+
40
+
41
+ def _load_safetensors(path: str) -> Tuple[Dict[str, torch.Tensor], Dict[str, str]]:
42
+ tensors: Dict[str, torch.Tensor] = {}
43
+ metadata: Dict[str, str] = {}
44
+ with safe_open(path, framework="pt") as f:
45
+ metadata = _ensure_metadata_str_dict(f.metadata())
46
+ for key in f.keys():
47
+ tensors[key] = f.get_tensor(key)
48
+ return tensors, metadata
49
+
50
+
51
+
52
+ def _detect_format(keys: Iterable[str]) -> str:
53
+ keys = list(keys)
54
+ if not keys:
55
+ return "empty"
56
+ sample = keys[0]
57
+ if sample.startswith("lora_unet_"):
58
+ return "kohya"
59
+ if sample.startswith("diffusion_model."):
60
+ return "diffusers"
61
+ if sample.startswith("transformer."):
62
+ return "transformer"
63
+ if sample.startswith("blocks."):
64
+ return "blocks"
65
+ if sample.startswith("base_model.model."):
66
+ return "peft"
67
+ return "other"
68
+
69
+
70
+
71
+ def _maybe_compensate_rs_lora(sd: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
72
+ """
73
+ Handle rank-stabilized PEFT-like LoRAs similarly to WanVideoWrapper.
74
+ When these files omit alpha, add a compensated alpha tensor.
75
+ """
76
+ probe_key = "base_model.model.blocks.0.cross_attn.k.lora_A.weight"
77
+ if probe_key not in sd:
78
+ return sd
79
+
80
+ rank = int(sd[probe_key].shape[0])
81
+ # Mirrors the idea used in WanVideoWrapper's compensate_rs_lora_format.
82
+ alpha_value = rank * rank // max(int(rank ** 0.5), 1)
83
+ alpha_tensor = torch.tensor(alpha_value, dtype=torch.int32)
84
+
85
+ out: Dict[str, torch.Tensor] = {}
86
+ for k, v in sd.items():
87
+ out[k] = v
88
+ if k.endswith(".lora_A.weight"):
89
+ alpha_key = k.replace(".lora_A.weight", ".alpha")
90
+ out.setdefault(alpha_key, alpha_tensor)
91
+ return out
92
+
93
+
94
+
95
+ def _standardize_key(key: str) -> Optional[str]:
96
+ """
97
+ Normalize several LoRA naming variants into the WAN2.2/Comfy diffusers-style
98
+ `diffusion_model.*` key space.
99
+
100
+ Supported sources:
101
+ - Kohya/Fun style: lora_unet_* and lora_unet__*
102
+ - Diffusers variants: transformer.*, blocks.*, base_model.model.*
103
+ - Already standardized: diffusion_model.*
104
+ """
105
+ k = key
106
+
107
+ # Already standardized.
108
+ if k.startswith("diffusion_model."):
109
+ return k
110
+
111
+ # Light normalization adopted from current WanVideoWrapper loading logic.
112
+ if k.startswith("transformer."):
113
+ k = k.replace("transformer.", "diffusion_model.", 1)
114
+ if k.startswith("pipe.dit."):
115
+ k = k.replace("pipe.dit.", "diffusion_model.", 1)
116
+ if k.startswith("blocks."):
117
+ k = k.replace("blocks.", "diffusion_model.blocks.", 1)
118
+ if k.startswith("vace_blocks."):
119
+ k = k.replace("vace_blocks.", "diffusion_model.vace_blocks.", 1)
120
+ if k.startswith("base_model.model."):
121
+ k = k.replace("base_model.model.", "diffusion_model.", 1)
122
+
123
+ k = k.replace(".default.", ".")
124
+ k = k.replace(".diff_m", ".modulation.diff")
125
+
126
+ if k.startswith("diffusion.model."):
127
+ k = k.replace("diffusion.model.", "diffusion_model.", 1)
128
+
129
+ if ".attn1." in k:
130
+ k = k.replace(".attn1.", ".cross_attn.")
131
+ k = k.replace(".to_k.", ".k.")
132
+ k = k.replace(".to_q.", ".q.")
133
+ k = k.replace(".to_v.", ".v.")
134
+ k = k.replace(".to_out.0.", ".o.")
135
+ elif ".attn2." in k:
136
+ k = k.replace(".attn2.", ".cross_attn.")
137
+ k = k.replace(".to_k.", ".k.")
138
+ k = k.replace(".to_q.", ".q.")
139
+ k = k.replace(".to_v.", ".v.")
140
+ k = k.replace(".to_out.0.", ".o.")
141
+
142
+ k = k.replace("img_attn.proj", "img_attn_proj")
143
+ k = k.replace("img_attn.qkv", "img_attn_qkv")
144
+ k = k.replace("txt_attn.proj", "txt_attn_proj")
145
+ k = k.replace("txt_attn.qkv", "txt_attn_qkv")
146
+
147
+ # AIToolkit/LyCORIS-ish shorthand.
148
+ if k.startswith("lycoris_blocks_"):
149
+ k = k.replace("lycoris_blocks_", "diffusion_model.blocks.", 1)
150
+ k = k.replace("_cross_attn_", ".cross_attn.")
151
+ k = k.replace("_self_attn_", ".self_attn.")
152
+ k = k.replace("_ffn_net_0_proj", ".ffn.0")
153
+ k = k.replace("_ffn_net_2", ".ffn.2")
154
+ k = k.replace("to_out_0", "o")
155
+ return k
156
+
157
+ # Kohya / Fun LoRA style with double underscore.
158
+ if k.startswith("lora_unet__"):
159
+ parts = k.split(".")
160
+ main_part = parts[0]
161
+ weight_type = ".".join(parts[1:]) if len(parts) > 1 else ""
162
+
163
+ if "blocks_" in main_part:
164
+ components = main_part[len("lora_unet__"):].split("_")
165
+ new_key = "diffusion_model"
166
+ if len(components) >= 2 and components[0] == "blocks":
167
+ new_key += f".blocks.{components[1]}"
168
+ idx = 2
169
+ if idx < len(components):
170
+ if idx + 1 < len(components) and components[idx] == "self" and components[idx + 1] == "attn":
171
+ new_key += ".self_attn"
172
+ idx += 2
173
+ elif idx + 1 < len(components) and components[idx] == "cross" and components[idx + 1] == "attn":
174
+ new_key += ".cross_attn"
175
+ idx += 2
176
+ elif components[idx] == "ffn":
177
+ new_key += ".ffn"
178
+ idx += 1
179
+
180
+ if idx < len(components):
181
+ component = components[idx]
182
+ idx += 1
183
+ if idx < len(components) and components[idx] == "img":
184
+ component += "_img"
185
+ new_key += f".{component}"
186
+
187
+ if weight_type == "alpha":
188
+ return new_key + ".alpha"
189
+ if weight_type in {"lora_down.weight", "lora_down"}:
190
+ return new_key + ".lora_A.weight"
191
+ if weight_type in {"lora_up.weight", "lora_up"}:
192
+ return new_key + ".lora_B.weight"
193
+ if weight_type:
194
+ return new_key + f".{weight_type}"
195
+ return new_key
196
+
197
+ # Fallback for remaining lora_unet__ patterns.
198
+ new_key = main_part.replace("lora_unet__", "diffusion_model.", 1)
199
+ new_key = new_key.replace("_self_attn", ".self_attn")
200
+ new_key = new_key.replace("_cross_attn", ".cross_attn")
201
+ new_key = new_key.replace("_ffn", ".ffn")
202
+ new_key = new_key.replace("blocks_", "blocks.")
203
+ new_key = new_key.replace("head_head", "head.head")
204
+ new_key = new_key.replace("text_embedding", "text.embedding")
205
+ new_key = new_key.replace("time_embedding", "time.embedding")
206
+ new_key = new_key.replace("time_projection", "time.projection")
207
+
208
+ rebuilt_parts = []
209
+ for part in new_key.split("."):
210
+ if part in {"img_emb", "self_attn", "cross_attn"}:
211
+ rebuilt_parts.append(part)
212
+ else:
213
+ rebuilt_parts.append(part.replace("_", "."))
214
+ new_key = ".".join(rebuilt_parts)
215
+
216
+ special_components = {
217
+ "time.projection": "time_projection",
218
+ "img.emb": "img_emb",
219
+ "text.emb": "text_emb",
220
+ "time.emb": "time_emb",
221
+ }
222
+ for old, new in special_components.items():
223
+ new_key = new_key.replace(old, new)
224
+
225
+ if weight_type == "alpha":
226
+ return new_key + ".alpha"
227
+ if weight_type in {"lora_down.weight", "lora_down"}:
228
+ return new_key + ".lora_A.weight"
229
+ if weight_type in {"lora_up.weight", "lora_up"}:
230
+ return new_key + ".lora_B.weight"
231
+ if weight_type:
232
+ return new_key + f".{weight_type}"
233
+ return new_key
234
+
235
+ # Kohya style from the user's original converter.
236
+ if k.startswith("lora_unet_"):
237
+ # alpha support
238
+ m = re.match(r"lora_unet_blocks_(\d+)_(cross_attn|self_attn)_(\w+)\.alpha$", k)
239
+ if m:
240
+ block_num, attn_type, sub_layer = m.groups()
241
+ return f"diffusion_model.blocks.{block_num}.{attn_type}.{sub_layer}.alpha"
242
+
243
+ m = re.match(r"lora_unet_blocks_(\d+)_ffn_(\d+)\.alpha$", k)
244
+ if m:
245
+ block_num, ffn_num = m.groups()
246
+ return f"diffusion_model.blocks.{block_num}.ffn.{ffn_num}.alpha"
247
+
248
+ m = re.match(r"lora_unet_blocks_(\d+)_(\w+)_(\w+)\.alpha$", k)
249
+ if m:
250
+ block_num, layer1, layer2 = m.groups()
251
+ return f"diffusion_model.blocks.{block_num}.{layer1}.{layer2}.alpha"
252
+
253
+ m = re.match(
254
+ r"lora_unet_blocks_(\d+)_(cross_attn|self_attn)_(\w+)\.(lora_down|lora_up)\.weight$",
255
+ k,
256
+ )
257
+ if m:
258
+ block_num, attn_type, sub_layer, matrix = m.groups()
259
+ matrix_new = "lora_A" if matrix == "lora_down" else "lora_B"
260
+ return f"diffusion_model.blocks.{block_num}.{attn_type}.{sub_layer}.{matrix_new}.weight"
261
+
262
+ m = re.match(r"lora_unet_blocks_(\d+)_ffn_(\d+)\.(lora_down|lora_up)\.weight$", k)
263
+ if m:
264
+ block_num, ffn_num, matrix = m.groups()
265
+ matrix_new = "lora_A" if matrix == "lora_down" else "lora_B"
266
+ return f"diffusion_model.blocks.{block_num}.ffn.{ffn_num}.{matrix_new}.weight"
267
+
268
+ m = re.match(r"lora_unet_blocks_(\d+)_(\w+)_(\w+)\.(lora_down|lora_up)\.weight$", k)
269
+ if m:
270
+ block_num, layer1, layer2, matrix = m.groups()
271
+ matrix_new = "lora_A" if matrix == "lora_down" else "lora_B"
272
+ return f"diffusion_model.blocks.{block_num}.{layer1}.{layer2}.{matrix_new}.weight"
273
+
274
+ return None
275
+
276
+ # If earlier normalization got us into the target namespace, keep it.
277
+ if k.startswith("diffusion_model."):
278
+ return k
279
+
280
+ return None
281
+
282
+
283
+
284
+ def _should_drop_key(raw_key: str, standardized_key: Optional[str], filter_img: bool, extra_patterns: Iterable[str]) -> bool:
285
+ raw_lower = raw_key.lower()
286
+ std_lower = (standardized_key or raw_key).lower()
287
+
288
+ if filter_img:
289
+ img_markers = (
290
+ "_img",
291
+ ".img_",
292
+ "img_emb",
293
+ "img_attn",
294
+ "clip_vision",
295
+ "clip.visual",
296
+ "clip_visual",
297
+ )
298
+ if any(marker in raw_lower for marker in img_markers) or any(marker in std_lower for marker in img_markers):
299
+ return True
300
+
301
+ for pattern in extra_patterns:
302
+ p = pattern.strip().lower()
303
+ if not p:
304
+ continue
305
+ if p in raw_lower or p in std_lower:
306
+ return True
307
+ return False
308
+
309
+
310
+
311
+ def _bake_strength_linear(state_dict: Dict[str, torch.Tensor], strength: float) -> Dict[str, torch.Tensor]:
312
+ """
313
+ Bake a *linear* LoRA strength by scaling only the up/B side.
314
+ Scaling both A and B would square the effective strength.
315
+ """
316
+ if strength == 1.0:
317
+ return dict(state_dict)
318
+
319
+ baked: Dict[str, torch.Tensor] = {}
320
+ for key, tensor in state_dict.items():
321
+ if key.endswith(".lora_B.weight") or key.endswith(".lora_up.weight"):
322
+ scaled = tensor.to(torch.float32) * float(strength)
323
+ baked[key] = scaled.to(tensor.dtype)
324
+ else:
325
+ baked[key] = tensor
326
+ return baked
327
+
328
+
329
+
330
+ def _convert_and_save(
331
+ input_path: str,
332
+ output_path: str,
333
+ baked_strength: float,
334
+ filter_img: bool,
335
+ extra_exclude: str = "",
336
+ ) -> Dict[str, object]:
337
+ src_sd, src_meta = _load_safetensors(input_path)
338
+ src_sd = _maybe_compensate_rs_lora(src_sd)
339
+
340
+ converted: Dict[str, torch.Tensor] = {}
341
+ filtered = 0
342
+ skipped_unmapped = 0
343
+ preserved = 0
344
+
345
+ extra_patterns = [p.strip() for p in extra_exclude.split(",") if p.strip()]
346
+
347
+ for raw_key, tensor in src_sd.items():
348
+ std_key = _standardize_key(raw_key)
349
+ if _should_drop_key(raw_key, std_key, filter_img=filter_img, extra_patterns=extra_patterns):
350
+ filtered += 1
351
+ continue
352
+
353
+ if std_key is None:
354
+ # Preserve already non-LoRA or uncommon keys only if they already live in target namespace.
355
+ # Otherwise skip because arbitrary passthrough keys are more likely to break WAN2.2 loading.
356
+ skipped_unmapped += 1
357
+ continue
358
+
359
+ if std_key in converted:
360
+ # Prefer the first occurrence; duplicates usually indicate multiple source aliases.
361
+ continue
362
+
363
+ converted[std_key] = tensor
364
+ preserved += 1
365
+
366
+ if not converted:
367
+ raise ValueError(
368
+ "No convertible WAN LoRA keys were produced. The file may not be a WAN2.1 LoRA in a supported format."
369
+ )
370
+
371
+ baked = _bake_strength_linear(converted, baked_strength)
372
+
373
+ meta = dict(src_meta)
374
+ meta.update(
375
+ {
376
+ "wan_toolkit.source_file": Path(input_path).name,
377
+ "wan_toolkit.converted_for": "WAN2.2",
378
+ "wan_toolkit.filter_img": str(bool(filter_img)).lower(),
379
+ "wan_toolkit.extra_exclude": extra_exclude,
380
+ "wan_toolkit.baked_strength": str(baked_strength),
381
+ "wan_toolkit.generated_at": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
382
+ }
383
+ )
384
+
385
+ output_path = str(Path(output_path))
386
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
387
+ save_file(baked, output_path, metadata=_ensure_metadata_str_dict(meta))
388
+
389
+ return {
390
+ "output_path": output_path,
391
+ "source_keys": len(src_sd),
392
+ "saved_keys": len(baked),
393
+ "filtered_keys": filtered,
394
+ "skipped_unmapped": skipped_unmapped,
395
+ "preserved_keys": preserved,
396
+ "baked_strength": baked_strength,
397
+ "detected_format": _detect_format(src_sd.keys()),
398
+ }
399
+
400
+
401
+ # ============================================================
402
+ # ComfyUI Node
403
+ # ============================================================
404
+
405
+
406
+ class WAN21ToWAN22HighLowConverter:
407
+ """
408
+ Convert a WAN2.1 LoRA from path input and emit baked WAN2.2 high/low files.
409
+ """
410
+
411
+ CATEGORY = "WAN/LoRA"
412
+ FUNCTION = "convert"
413
+ RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
414
+ RETURN_NAMES = ("high_path", "low_path", "output_dir", "report")
415
+ OUTPUT_NODE = True
416
+
417
+ @classmethod
418
+ def INPUT_TYPES(cls):
419
+ return {
420
+ "required": {
421
+ "input_lora_path": (
422
+ "STRING",
423
+ {
424
+ "default": "",
425
+ "multiline": False,
426
+ "placeholder": "/full/path/to/wan21_lora.safetensors",
427
+ },
428
+ ),
429
+ "high_strength": (
430
+ "FLOAT",
431
+ {"default": 1.75, "min": 0.0, "max": 10.0, "step": 0.05},
432
+ ),
433
+ "low_strength": (
434
+ "FLOAT",
435
+ {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.05},
436
+ ),
437
+ },
438
+ "optional": {
439
+ "output_dir": (
440
+ "STRING",
441
+ {
442
+ "default": "",
443
+ "multiline": False,
444
+ "placeholder": "leave empty = same folder as input",
445
+ },
446
+ ),
447
+ "filter_img_keys": (
448
+ "BOOLEAN",
449
+ {"default": True},
450
+ ),
451
+ "extra_exclude": (
452
+ "STRING",
453
+ {
454
+ "default": "",
455
+ "multiline": False,
456
+ "placeholder": "comma-separated substrings to drop",
457
+ },
458
+ ),
459
+ },
460
+ }
461
+
462
+ @classmethod
463
+ def IS_CHANGED(
464
+ cls,
465
+ input_lora_path,
466
+ high_strength,
467
+ low_strength,
468
+ output_dir="",
469
+ filter_img_keys=True,
470
+ extra_exclude="",
471
+ ):
472
+ path = _clean_path(input_lora_path)
473
+ if not path or not Path(path).exists():
474
+ return f"missing:{path}|{high_strength}|{low_strength}|{output_dir}|{filter_img_keys}|{extra_exclude}"
475
+ return (
476
+ f"{_file_signature(path)}|{high_strength}|{low_strength}|"
477
+ f"{_clean_path(output_dir)}|{bool(filter_img_keys)}|{extra_exclude}"
478
+ )
479
+
480
+ def convert(
481
+ self,
482
+ input_lora_path,
483
+ high_strength,
484
+ low_strength,
485
+ output_dir="",
486
+ filter_img_keys=True,
487
+ extra_exclude="",
488
+ ):
489
+ input_lora_path = _clean_path(input_lora_path)
490
+ output_dir = _clean_path(output_dir)
491
+
492
+ if not input_lora_path:
493
+ raise ValueError("input_lora_path is empty")
494
+
495
+ src = Path(input_lora_path)
496
+ if not src.exists():
497
+ raise FileNotFoundError(f"Input file not found: {input_lora_path}")
498
+ if src.suffix.lower() != ".safetensors":
499
+ raise ValueError("Input file must be a .safetensors file")
500
+
501
+ if not output_dir:
502
+ output_dir = str(src.parent)
503
+
504
+ out_dir = Path(output_dir)
505
+ out_dir.mkdir(parents=True, exist_ok=True)
506
+
507
+ base = src.stem
508
+ hi_path = str(out_dir / f"{base}_HI.safetensors")
509
+ lo_path = str(out_dir / f"{base}_LO.safetensors")
510
+
511
+ hi = _convert_and_save(
512
+ input_path=str(src),
513
+ output_path=hi_path,
514
+ baked_strength=float(high_strength),
515
+ filter_img=bool(filter_img_keys),
516
+ extra_exclude=extra_exclude,
517
+ )
518
+ lo = _convert_and_save(
519
+ input_path=str(src),
520
+ output_path=lo_path,
521
+ baked_strength=float(low_strength),
522
+ filter_img=bool(filter_img_keys),
523
+ extra_exclude=extra_exclude,
524
+ )
525
+
526
+ report = {
527
+ "source": str(src),
528
+ "output_dir": str(out_dir),
529
+ "high": hi,
530
+ "low": lo,
531
+ }
532
+
533
+ return (hi_path, lo_path, str(out_dir), json.dumps(report, indent=2))
534
+
535
+
536
+ NODE_CLASS_MAPPINGS = {
537
+ "WAN21ToWAN22HighLowConverter": WAN21ToWAN22HighLowConverter,
538
+ }
539
+
540
+ NODE_DISPLAY_NAME_MAPPINGS = {
541
+ "WAN21ToWAN22HighLowConverter": "WAN 2.1 → 2.2 LoRA Converter (HI/LO)",
542
+ }