dagloop5 commited on
Commit
8c516bc
·
verified ·
1 Parent(s): 40f574f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +101 -48
app.py CHANGED
@@ -67,10 +67,14 @@ DEFAULT_LORA_D_STRENGTH = 0.0
67
  DEFAULT_LORA_E_STRENGTH = 0.0
68
  DEFAULT_LORA_F_STRENGTH = 0.0
69
  DEFAULT_LORA_G_STRENGTH = 0.0
70
- # Some `diffusion_model.blocks.*` checkpoints store SwiGLU's fc1 gate/value halves in the opposite order
71
- # diffusers expects. Leave off first; if the LoRA's effect looks inverted/broken rather than just weak or
72
- # strong, set H3_LORA_SWAP_FC1=1 and compare.
73
- SWAP_FC1_HALVES = os.environ.get("H3_LORA_SWAP_FC1", "0") == "1"
 
 
 
 
74
 
75
  # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not
76
  # know is rejected there and surfaces as a failure here. This is the workflow's "Target Dimension" node.
@@ -155,39 +159,71 @@ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
155
 
156
  MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
157
 
158
- def _convert_diffusion_model_lora(raw: dict, base_shapes: dict) -> dict:
159
  """Rename a `diffusion_model.blocks.*` (original-checkpoint) LoRA state dict onto
160
  `MiniMaxH3Transformer3DModel`'s (`transformer_blocks.*`) naming, so `load_lora_adapter` can attach it.
161
  `raw` maps original key -> tensor. `base_shapes` maps the *unwrapped* base model's parameter names to their
162
  shapes — captured once before any adapter is attached, since `load_lora_adapter` wraps each target Linear in
163
  a PEFT layer and renames its weight to `<name>.base_layer.weight`, so a live `transformer.state_dict()` call
164
  after the first adapter attaches would no longer have `to_q.weight` etc. under their original names.
 
 
 
 
 
 
 
165
  """
166
  import re
167
 
168
- out = {}
 
169
 
170
- # Family A: `[diffusion_model.]blocks.N.(attn|mlp|adaln_proj).LEAF.(lora_A|lora_B).weight` — covers Mylo,
171
- # VBVR, AIO_V2, moawxx, the Furry Realism LoRA, and (minus its `diffusion_model.` prefix) the Turbo LoRA.
172
- standard = re.compile(
173
- r"^(?:diffusion_model\.)?blocks\.(\d+)\.(attn|mlp|adaln_proj)\.([\w.]+)\.(lora_[AB])\.weight$"
174
- )
175
- # Family B (Kohya-style): `lora_unet_blocks_N_TARGET.(lora_down|lora_up).weight` — covers SB and Fluid
 
 
 
176
  # Enhancer. `lora_down`/`lora_up` are the same A/B convention under a different name.
177
- kohya = re.compile(
178
  r"^lora_unet_blocks_(\d+)_(attn_out_proj|attn_qkv_proj|mlp_fc1|mlp_fc2)\.(lora_down|lora_up)\.weight$"
179
  )
 
180
  kohya_targets = {
181
  "attn_out_proj": ("attn", "out_proj"),
182
  "attn_qkv_proj": ("attn", "qkv_proj"),
183
  "mlp_fc1": ("mlp", "fc1"),
184
  "mlp_fc2": ("mlp", "fc2"),
185
  }
186
- kohya_ab = {"lora_down": "lora_A", "lora_up": "lora_B"}
187
-
188
- def emit(block: str, kind: str, leaf: str, ab: str, tensor) -> None:
189
- prefix = f"transformer_blocks.{block}."
190
- if kind == "attn" and leaf == "qkv_proj":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  if ab == "lora_A":
192
  # Shared low-rank input side — identical for q, k, v.
193
  out[f"{prefix}attn.to_q.{ab}.weight"] = tensor
@@ -198,57 +234,70 @@ def _convert_diffusion_model_lora(raw: dict, base_shapes: dict) -> dict:
198
  k_out = base_shapes[f"{prefix}attn.to_k.weight"][0]
199
  v_out = base_shapes[f"{prefix}attn.to_v.weight"][0]
200
  assert tensor.shape[0] == q_out + k_out + v_out, (
201
- f"blocks.{block}.attn.qkv_proj.{ab}: expected {q_out + k_out + v_out} rows "
202
  f"(q{q_out}+k{k_out}+v{v_out}), got {tensor.shape[0]}"
203
  )
204
  out[f"{prefix}attn.to_q.{ab}.weight"] = tensor[:q_out].clone()
205
  out[f"{prefix}attn.to_k.{ab}.weight"] = tensor[q_out:q_out + k_out].clone()
206
  out[f"{prefix}attn.to_v.{ab}.weight"] = tensor[q_out + k_out:].clone()
207
- elif kind == "attn" and leaf == "out_proj":
208
- out[f"{prefix}attn.to_out.0.{ab}.weight"] = tensor
209
- elif kind == "mlp" and leaf == "fc1":
210
- if ab == "lora_B" and SWAP_FC1_HALVES:
211
- half = tensor.shape[0] // 2
212
- tensor = torch.cat([tensor[half:], tensor[:half]], dim=0)
213
- out[f"{prefix}ff.net.0.proj.{ab}.weight"] = tensor
214
- elif kind == "mlp" and leaf == "fc2":
215
- out[f"{prefix}ff.net.2.{ab}.weight"] = tensor
216
- elif kind == "adaln_proj" and leaf == "linear":
217
- out[f"{prefix}adaln_proj.linear.{ab}.weight"] = tensor
218
- else:
219
- print(f"[lora-convert] no mapping for blocks.{block}.{kind}.{leaf}.{ab}, skipping", flush=True)
 
220
 
221
  for key, raw_tensor in raw.items():
222
  # Some files (fp16-labeled ones especially) don't match the bf16 transformer's dtype; PEFT expects the
223
  # adapter's dtype to match the wrapped base layer's.
224
  tensor = raw_tensor.to(torch.bfloat16)
225
 
226
- match = standard.match(key)
227
  if match:
228
- block, kind, leaf, ab = match.groups()
229
- emit(block, kind, leaf, ab, tensor)
230
  continue
231
 
232
- match = kohya.match(key)
233
  if match:
234
  block, target, direction = match.groups()
235
  kind, leaf = kohya_targets[target]
236
- emit(block, kind, leaf, kohya_ab[direction], tensor)
237
  continue
238
 
239
- if key.endswith(".alpha"):
240
- # Per-module rank/alpha scaling isn't threaded through — matched modules get PEFT's default scaling
241
- # (scale 1.0), and the UI slider is what actually controls each LoRA's visible strength here. This
242
- # is a known simplification: a file's built-in alpha may have scaled it up or down from its raw
243
- # rank, so its slider range that "feels right" may not match what the file's author intended or
244
- # tested at. It isn't a bug — the Furry Realism LoRA's `.alpha` keys were already dropped the same
245
- # way and it loads and works fine — just worth knowing if a LoRA's effect seems unexpectedly
246
- # weak/strong across its whole slider range rather than at a specific value.
 
 
 
247
  continue
248
 
249
  print(f"[lora-convert] skipping unrecognized key: {key}", flush=True)
250
 
251
- return out
 
 
 
 
 
 
 
 
 
252
 
253
  PIPE = None
254
  FILM = None
@@ -354,8 +403,12 @@ def load_models() -> str | None:
354
  path = hf_hub_download(LORA_REPO, filename)
355
  with safe_open(path, framework="pt") as handle:
356
  raw = {k: handle.get_tensor(k) for k in handle.keys()}
357
- converted = _convert_diffusion_model_lora(raw, base_shapes)
358
- pipe.transformer.load_lora_adapter(converted, adapter_name=name, prefix=None)
 
 
 
 
359
  # `load_lora_adapter` warns-and-continues on a zero-key match instead of raising, so count
360
  # matched layers ourselves and fail loudly if a file attached nothing.
361
  matched = sum(
 
67
  DEFAULT_LORA_E_STRENGTH = 0.0
68
  DEFAULT_LORA_F_STRENGTH = 0.0
69
  DEFAULT_LORA_G_STRENGTH = 0.0
70
+ # Per-LoRA, not global: different training pipelines can store SwiGLU's fc1 gate/value halves in either order,
71
+ # and one flag can only be right for however many of the 8 files happen to agree. `lora1` (the Distilled/Turbo
72
+ # LoRA) is confirmed needing the swap by InstantX's official conversion of the same lineage
73
+ # (MiniMax-H3-Turbo-Lora-Diffusers/convert.py: "SwiGLU fc1 halves are swapped to match Diffusers' [value; gate]
74
+ # layout"); the rest default off until tested individually — set H3_LORA_SWAP_FC1_NAMES to a comma-separated
75
+ # list of LORA_FILES keys (e.g. "lora1,lorac") to override. Replaces H3_LORA_SWAP_FC1, which no longer does
76
+ # anything.
77
+ SWAP_FC1_NAMES = {name for name in os.environ.get("H3_LORA_SWAP_FC1_NAMES", "lora1").split(",") if name}
78
 
79
  # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not
80
  # know is rejected there and surfaces as a failure here. This is the workflow's "Target Dimension" node.
 
159
 
160
  MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
161
 
162
+ def _convert_diffusion_model_lora(raw: dict, base_shapes: dict, swap_fc1: bool) -> tuple[dict, dict]:
163
  """Rename a `diffusion_model.blocks.*` (original-checkpoint) LoRA state dict onto
164
  `MiniMaxH3Transformer3DModel`'s (`transformer_blocks.*`) naming, so `load_lora_adapter` can attach it.
165
  `raw` maps original key -> tensor. `base_shapes` maps the *unwrapped* base model's parameter names to their
166
  shapes — captured once before any adapter is attached, since `load_lora_adapter` wraps each target Linear in
167
  a PEFT layer and renames its weight to `<name>.base_layer.weight`, so a live `transformer.state_dict()` call
168
  after the first adapter attaches would no longer have `to_q.weight` etc. under their original names.
169
+
170
+ Returns `(converted_weights, network_alphas)` — `network_alphas` is `load_lora_adapter`'s per-module `alpha`
171
+ map. Built for every converted module, not just ones whose raw file carries an explicit `.alpha` key: PEFT's
172
+ default scaling isn't guaranteed to land on `alpha == rank` when a LoRA mixes ranks across target types —
173
+ InstantX's own Turbo-LoRA conversion needs `network_alphas` for exactly this reason (attn/mlp modules rank
174
+ 64, AdaLN modules rank 16), even though that file carries no `.alpha` keys at all. So `alpha = rank` is
175
+ synthesized for every module first, then overridden wherever the raw file specifies something else.
176
  """
177
  import re
178
 
179
+ out: dict = {}
180
+ raw_alphas: dict[str, float] = {} # raw ComfyUI base name -> alpha, from real `.alpha` keys only
181
 
182
+ # Family A: standard (non-Kohya) naming — covers Mylo, VBVR, AIO_V2, moawxx, Anthro Realism, and (once
183
+ # `diffusion_model.` is stripped) the Distilled/Turbo LoRA. Matched by module base name rather than one
184
+ # fixed pattern, and renamed by substitution — this is what lets `token_refiner.blocks.*` and the top-level
185
+ # `final_layer.adaln_proj` resolve onto real targets instead of falling through unmatched. Ported from
186
+ # InstantX's official `MiniMax-H3-Turbo-Lora-Diffusers/convert.py`, written for this exact LoRA family.
187
+ standard_ab = re.compile(r"^(?:diffusion_model\.)?(.+)\.(lora_[AB])\.weight$")
188
+ standard_alpha = re.compile(r"^(?:diffusion_model\.)?(.+)\.alpha$")
189
+
190
+ # Family B (Kohya-style): `lora_unet_blocks_N_TARGET.(lora_down|lora_up|alpha)` — covers SB and Fluid
191
  # Enhancer. `lora_down`/`lora_up` are the same A/B convention under a different name.
192
+ kohya_ab = re.compile(
193
  r"^lora_unet_blocks_(\d+)_(attn_out_proj|attn_qkv_proj|mlp_fc1|mlp_fc2)\.(lora_down|lora_up)\.weight$"
194
  )
195
+ kohya_alpha = re.compile(r"^lora_unet_blocks_(\d+)_(attn_out_proj|attn_qkv_proj|mlp_fc1|mlp_fc2)\.alpha$")
196
  kohya_targets = {
197
  "attn_out_proj": ("attn", "out_proj"),
198
  "attn_qkv_proj": ("attn", "qkv_proj"),
199
  "mlp_fc1": ("mlp", "fc1"),
200
  "mlp_fc2": ("mlp", "fc2"),
201
  }
202
+ kohya_ab_name = {"lora_down": "lora_A", "lora_up": "lora_B"}
203
+
204
+ def rename_base(name: str) -> str:
205
+ """ComfyUI module path (before `.lora_*`/`.alpha`) -> Diffusers module path."""
206
+ if name.startswith("token_refiner.blocks."):
207
+ name = "token_refiner.refiner_blocks." + name[len("token_refiner.blocks."):]
208
+ elif name.startswith("blocks."):
209
+ name = "transformer_blocks." + name[len("blocks."):]
210
+ name = name.replace("final_layer.adaln_proj.linear", "norm_out.linear")
211
+ name = name.replace(".attn.out_proj", ".attn.to_out.0")
212
+ name = name.replace(".mlp.fc2", ".ff.net.2")
213
+ name = name.replace(".mlp.fc1", ".ff.net.0.proj")
214
+ return name
215
+
216
+ def target_bases(raw_base: str) -> list[str]:
217
+ """Diffusers-side base name(s) for one pre-rename module path — one, except `attn.qkv_proj`, which fans
218
+ out to `to_q`/`to_k`/`to_v` (same rank, so the same alpha applies to all three)."""
219
+ if raw_base.endswith(".attn.qkv_proj"):
220
+ prefix = rename_base(raw_base[: -len("attn.qkv_proj")])
221
+ return [f"{prefix}attn.to_q", f"{prefix}attn.to_k", f"{prefix}attn.to_v"]
222
+ return [rename_base(raw_base)]
223
+
224
+ def emit(raw_base: str, ab: str, tensor) -> None:
225
+ if raw_base.endswith(".attn.qkv_proj"):
226
+ prefix = rename_base(raw_base[: -len("attn.qkv_proj")])
227
  if ab == "lora_A":
228
  # Shared low-rank input side — identical for q, k, v.
229
  out[f"{prefix}attn.to_q.{ab}.weight"] = tensor
 
234
  k_out = base_shapes[f"{prefix}attn.to_k.weight"][0]
235
  v_out = base_shapes[f"{prefix}attn.to_v.weight"][0]
236
  assert tensor.shape[0] == q_out + k_out + v_out, (
237
+ f"{raw_base}.{ab}: expected {q_out + k_out + v_out} rows "
238
  f"(q{q_out}+k{k_out}+v{v_out}), got {tensor.shape[0]}"
239
  )
240
  out[f"{prefix}attn.to_q.{ab}.weight"] = tensor[:q_out].clone()
241
  out[f"{prefix}attn.to_k.{ab}.weight"] = tensor[q_out:q_out + k_out].clone()
242
  out[f"{prefix}attn.to_v.{ab}.weight"] = tensor[q_out + k_out:].clone()
243
+ return
244
+
245
+ if raw_base.endswith(".mlp.fc1") and ab == "lora_B" and swap_fc1:
246
+ half = tensor.shape[0] // 2
247
+ tensor = torch.cat([tensor[half:], tensor[:half]], dim=0)
248
+
249
+ key_base = rename_base(raw_base)
250
+ if f"{key_base}.weight" not in base_shapes:
251
+ # The more permissive substitution-based rename can produce a name that isn't an actual target on
252
+ # the live model validated here rather than trusting the rename blindly, since it no longer
253
+ # checks against a fixed whitelist of known `kind`s the way the old anchored regex did.
254
+ print(f"[lora-convert] '{raw_base}' renamed to '{key_base}', which isn't a real target — skipping", flush=True)
255
+ return
256
+ out[f"{key_base}.{ab}.weight"] = tensor
257
 
258
  for key, raw_tensor in raw.items():
259
  # Some files (fp16-labeled ones especially) don't match the bf16 transformer's dtype; PEFT expects the
260
  # adapter's dtype to match the wrapped base layer's.
261
  tensor = raw_tensor.to(torch.bfloat16)
262
 
263
+ match = standard_ab.match(key)
264
  if match:
265
+ raw_base, ab = match.groups()
266
+ emit(raw_base, ab, tensor)
267
  continue
268
 
269
+ match = kohya_ab.match(key)
270
  if match:
271
  block, target, direction = match.groups()
272
  kind, leaf = kohya_targets[target]
273
+ emit(f"blocks.{block}.{kind}.{leaf}", kohya_ab_name[direction], tensor)
274
  continue
275
 
276
+ match = standard_alpha.match(key)
277
+ if match:
278
+ (raw_base,) = match.groups()
279
+ raw_alphas[raw_base] = float(raw_tensor)
280
+ continue
281
+
282
+ match = kohya_alpha.match(key)
283
+ if match:
284
+ block, target = match.groups()
285
+ kind, leaf = kohya_targets[target]
286
+ raw_alphas[f"blocks.{block}.{kind}.{leaf}"] = float(raw_tensor)
287
  continue
288
 
289
  print(f"[lora-convert] skipping unrecognized key: {key}", flush=True)
290
 
291
+ network_alphas: dict[str, float] = {}
292
+ for out_key, out_tensor in out.items():
293
+ if out_key.endswith(".lora_B.weight"):
294
+ base = out_key[: -len(".lora_B.weight")]
295
+ network_alphas[f"{base}.alpha"] = float(out_tensor.shape[1])
296
+ for raw_base, alpha in raw_alphas.items():
297
+ for base in target_bases(raw_base):
298
+ network_alphas[f"{base}.alpha"] = alpha
299
+
300
+ return out, network_alphas
301
 
302
  PIPE = None
303
  FILM = None
 
403
  path = hf_hub_download(LORA_REPO, filename)
404
  with safe_open(path, framework="pt") as handle:
405
  raw = {k: handle.get_tensor(k) for k in handle.keys()}
406
+ converted, network_alphas = _convert_diffusion_model_lora(
407
+ raw, base_shapes, swap_fc1=name in SWAP_FC1_NAMES
408
+ )
409
+ pipe.transformer.load_lora_adapter(
410
+ converted, adapter_name=name, prefix=None, network_alphas=network_alphas
411
+ )
412
  # `load_lora_adapter` warns-and-continues on a zero-key match instead of raising, so count
413
  # matched layers ourselves and fail loudly if a file attached nothing.
414
  matched = sum(