amisima commited on
Commit
d0795e0
·
verified ·
1 Parent(s): 3320577

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +196 -8
app.py CHANGED
@@ -70,6 +70,10 @@ MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
70
  # for two subjects should not open with nine boxes.
71
  MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
72
 
 
 
 
 
73
  # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
74
  # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
75
  STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
@@ -79,6 +83,8 @@ PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
79
  AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
80
  REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
81
  DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
 
 
82
 
83
 
84
  def snap_frames(seconds: float) -> int:
@@ -151,7 +157,9 @@ def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
151
  return rows
152
 
153
 
154
- def get_duration(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, **_):
 
 
155
  """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
156
  tolerates the `gr.Progress` `spaces` injects."""
157
  sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
@@ -162,7 +170,7 @@ def get_duration(prompt_embeds, text_token_tags, references, height, width, num_
162
  # they are handed rather than with the step count.
163
  encode = 5 + reference_rows(references, num_frames) * 1e-3
164
  decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
165
- total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10
166
  duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
167
  print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
168
  return duration
@@ -254,6 +262,126 @@ def _arm_decode_hooks(pipe):
254
  setattr(module, method, armed)
255
 
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  @cache
258
  def conditioner():
259
  """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
@@ -374,18 +502,23 @@ def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
374
 
375
 
376
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
377
- def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed):
378
  """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
379
 
380
  References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
381
  argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
382
  the full `PipelineState` still holds the packed latents and the rotary grid on the card.
 
 
 
383
  """
384
  import torch
385
 
386
  if PLACEMENT == "lazy":
387
  PIPE.to("cuda")
388
 
 
 
389
  state = PIPE(
390
  prompt_embeds=prompt_embeds.to("cuda"),
391
  text_token_tags=text_token_tags,
@@ -421,9 +554,11 @@ def generate(
421
  steps=28,
422
  seed=42,
423
  upsample=False,
 
424
  progress=gr.Progress(track_tqdm=True),
425
  ):
426
- """One request."""
 
427
  if LOAD_ERROR:
428
  raise gr.Error(LOAD_ERROR)
429
  if PIPE is None:
@@ -440,6 +575,8 @@ def generate(
440
  derivable = len(audio_bearing(references)) == 1
441
  requested = 0 if (match and derivable) else snap_frames(duration)
442
 
 
 
443
  progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
444
  conditioned = time.time()
445
  try:
@@ -463,7 +600,7 @@ def generate(
463
  progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
464
  started = time.time()
465
  frames, audio, sampling_rate = _generate(
466
- prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed
467
  )
468
  generate_seconds = time.time() - started
469
 
@@ -477,12 +614,25 @@ def generate(
477
  f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
478
  f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
479
  f"denoise + decode {generate_seconds:.0f}s "
480
- f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}",
 
481
  flush=True,
482
  )
483
  return path, refined, gr.update(visible=bool(refined))
484
 
485
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  load_models()
487
 
488
  INTRO = """# MiniMax-H3 Reference
@@ -498,6 +648,11 @@ fully synchronized soundtrack (ambience, foley, speech). Bring your own subject,
498
  reference.
499
  """
500
 
 
 
 
 
 
501
  CSS = """
502
  .main.fillable { max-width: 1250px !important; }
503
  .dark .gradio-container { color: var(--body-text-color); }
@@ -538,6 +693,32 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
538
  with gr.Tab("Video"):
539
  video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
540
  run = gr.Button("Generate", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  with gr.Accordion("Advanced options", open=False):
542
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
543
  match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
@@ -570,8 +751,15 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
570
  duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
571
  )
572
 
573
- # Same order as `generate`'s signature: the exampled five first, then the remaining image slots.
574
- request = [prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample]
 
 
 
 
 
 
 
575
 
576
  gr.Examples(
577
  examples=[
 
70
  # for two subjects should not open with nine boxes.
71
  MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
72
 
73
+ # How many LoRA slots the UI offers, and the range each strength slider covers.
74
+ LORA_SLOTS = 3
75
+ LORA_MIN_SCALE, LORA_MAX_SCALE = -2.0, 2.0
76
+
77
  # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
78
  # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
79
  STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
 
83
  AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
84
  REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
85
  DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
86
+ # Reading one adapter off local disk and injecting it across the 33B transformer's linear layers.
87
+ LORA_ALLOWANCE = 12
88
 
89
 
90
  def snap_frames(seconds: float) -> int:
 
157
  return rows
158
 
159
 
160
+ def get_duration(
161
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=(), **_
162
+ ):
163
  """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
164
  tolerates the `gr.Progress` `spaces` injects."""
165
  sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
 
170
  # they are handed rather than with the step count.
171
  encode = 5 + reference_rows(references, num_frames) * 1e-3
172
  decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
173
+ total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10 + LORA_ALLOWANCE * len(loras or ())
174
  duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
175
  print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
176
  return duration
 
262
  setattr(module, method, armed)
263
 
264
 
265
+ # ----------------------------------------------------------------------------------------------------------------
266
+ # LoRA
267
+ # ----------------------------------------------------------------------------------------------------------------
268
+ # There is no `MiniMaxH3LoraLoaderMixin` in the diffusers integration, so adapters are attached at the *model* level,
269
+ # through the `PeftAdapterMixin` the transformer carries. That is the whole API this needs: `load_lora_adapter` for
270
+ # each file and one `set_adapters` call to give them their strengths. Here the model is `transformer_ref`, so the
271
+ # adapters have to be trained against the `transformer_ref/` partition — a `transformer/` adapter is a different
272
+ # partition and will not match.
273
+
274
+
275
+ def _hub_url_parts(url: str) -> tuple[str, str]:
276
+ """Split a huggingface.co `blob`/`resolve` URL into its repo id and the file path inside it."""
277
+ from urllib.parse import unquote, urlparse
278
+
279
+ parts = unquote(urlparse(url).path).strip("/").split("/")
280
+ if len(parts) < 5 or parts[2] not in ("resolve", "blob"):
281
+ raise gr.Error(f"Не разпознавам този адрес като файл в Hugging Face: `{url}`")
282
+ return "/".join(parts[:2]), "/".join(parts[4:])
283
+
284
+
285
+ def resolve_lora(reference: str) -> str:
286
+ """Turn what the user typed into a local `.safetensors` path.
287
+
288
+ Accepts a local path, a huggingface.co file URL, `owner/repo/path/to/file.safetensors`, or a bare `owner/repo`
289
+ whose single `.safetensors` is then picked for them. Runs outside the GPU call, so the download costs no GPU time.
290
+ """
291
+ from huggingface_hub import hf_hub_download, list_repo_files
292
+
293
+ reference = (reference or "").strip()
294
+ if not reference:
295
+ return ""
296
+ if os.path.exists(reference):
297
+ return reference
298
+ if reference.startswith(("http://", "https://")):
299
+ repo_id, filename = _hub_url_parts(reference)
300
+ return hf_hub_download(repo_id, filename)
301
+
302
+ parts = [part for part in reference.split("/") if part]
303
+ if len(parts) > 2 and parts[-1].endswith(".safetensors"):
304
+ return hf_hub_download("/".join(parts[:2]), "/".join(parts[2:]))
305
+ if len(parts) != 2:
306
+ raise gr.Error(
307
+ f"`{reference}` не е нито съществуващ файл, нито `автор/хранилище`, нито адрес към Hugging Face."
308
+ )
309
+
310
+ candidates = [name for name in list_repo_files(reference) if name.endswith(".safetensors")]
311
+ if not candidates:
312
+ raise gr.Error(f"В `{reference}` няма `.safetensors` файл.")
313
+ if len(candidates) > 1:
314
+ preferred = [name for name in candidates if "lora" in name.lower()]
315
+ if len(preferred) != 1:
316
+ listed = ", ".join(f"`{name}`" for name in sorted(candidates)[:8])
317
+ raise gr.Error(f"`{reference}` съдържа няколко файла. Напиши `{reference}/име.safetensors`. Има: {listed}")
318
+ candidates = preferred
319
+ return hf_hub_download(reference, candidates[0])
320
+
321
+
322
+ def _lora_prefix(state_dict) -> str | None:
323
+ """The prefix `load_lora_adapter` has to strip before the keys match the transformer's own module names."""
324
+ key = next(iter(state_dict))
325
+ for prefix in ("model.diffusion_model", "diffusion_model", "transformer_ref", "transformer"):
326
+ if key.startswith(f"{prefix}."):
327
+ return prefix
328
+ return None
329
+
330
+
331
+ def apply_loras(transformer, loras) -> list[str]:
332
+ """Attach `loras` (local path, strength) to `transformer` and give each its strength, replacing whatever was on it.
333
+
334
+ Every adapter already on the model is removed first, so a request is never affected by the one before it — which
335
+ matters when a worker is reused rather than forked fresh.
336
+ """
337
+ import torch
338
+
339
+ from safetensors.torch import load_file
340
+
341
+ for name in list(getattr(transformer, "peft_config", None) or {}):
342
+ transformer.delete_adapters(name)
343
+
344
+ names, scales = [], []
345
+ for index, (path, scale) in enumerate(loras):
346
+ state_dict = load_file(path)
347
+ name = f"lora{index}"
348
+ transformer.load_lora_adapter(state_dict, adapter_name=name, prefix=_lora_prefix(state_dict))
349
+ names.append(name)
350
+ scales.append(float(scale))
351
+
352
+ if not names:
353
+ return []
354
+
355
+ # PEFT builds the new layers on its own default device/dtype; the base weights are the truth here, under either
356
+ # placement mode (`offload` keeps them on the host and moves whole modules by hook).
357
+ base = next(param for key, param in transformer.named_parameters() if ".lora_" not in key)
358
+ with torch.no_grad():
359
+ for key, param in transformer.named_parameters():
360
+ if ".lora_" in key and (param.device != base.device or param.dtype != base.dtype):
361
+ param.data = param.data.to(device=base.device, dtype=base.dtype)
362
+
363
+ transformer.set_adapters(names, scales)
364
+ return names
365
+
366
+
367
+ def collect_loras(lora_fields, progress) -> tuple[list[tuple[str, float]], list[str]]:
368
+ """Resolve the UI's `reference, strength, reference, strength, ...` into `(local path, strength)` pairs.
369
+
370
+ Resolved before the booking: a download that happens inside `@spaces.GPU` is billed as GPU time.
371
+ """
372
+ loras, labels = [], []
373
+ for reference, scale in zip(lora_fields[::2], lora_fields[1::2]):
374
+ reference = (reference or "").strip()
375
+ if not reference or abs(float(scale)) < 1e-6:
376
+ continue
377
+ progress(0.0, desc=f"Fetching LoRA {reference} ...")
378
+ loras.append((resolve_lora(reference), float(scale)))
379
+ labels.append(f"{os.path.basename(reference)} @ {float(scale):g}")
380
+ if loras and os.environ.get("H3_AOTI") == "1":
381
+ raise gr.Error("LoRA не може да се приложи върху AoTI компилиран трансформър. Изключи `H3_AOTI`.")
382
+ return loras, labels
383
+
384
+
385
  @cache
386
  def conditioner():
387
  """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
 
502
 
503
 
504
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
505
+ def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=()):
506
  """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
507
 
508
  References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
509
  argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
510
  the full `PipelineState` still holds the packed latents and the rotary grid on the card.
511
+
512
+ The adapters are attached here rather than in the caller: `spaces` runs this body in its own worker, so the
513
+ transformer the request sees is the one that has to carry them.
514
  """
515
  import torch
516
 
517
  if PLACEMENT == "lazy":
518
  PIPE.to("cuda")
519
 
520
+ apply_loras(PIPE.transformer_ref, loras or ())
521
+
522
  state = PIPE(
523
  prompt_embeds=prompt_embeds.to("cuda"),
524
  text_token_tags=text_token_tags,
 
554
  steps=28,
555
  seed=42,
556
  upsample=False,
557
+ *lora_fields,
558
  progress=gr.Progress(track_tqdm=True),
559
  ):
560
+ """One request. The LoRA fields are last and default to empty, so a positional API client that predates them is
561
+ unaffected. `lora_fields` arrives as `reference, strength, reference, strength, ...`."""
562
  if LOAD_ERROR:
563
  raise gr.Error(LOAD_ERROR)
564
  if PIPE is None:
 
575
  derivable = len(audio_bearing(references)) == 1
576
  requested = 0 if (match and derivable) else snap_frames(duration)
577
 
578
+ loras, lora_labels = collect_loras(lora_fields, progress)
579
+
580
  progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
581
  conditioned = time.time()
582
  try:
 
600
  progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
601
  started = time.time()
602
  frames, audio, sampling_rate = _generate(
603
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras
604
  )
605
  generate_seconds = time.time() - started
606
 
 
614
  f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
615
  f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
616
  f"denoise + decode {generate_seconds:.0f}s "
617
+ f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}"
618
+ f"{' · LoRA ' + ', '.join(lora_labels) if lora_labels else ''}",
619
  flush=True,
620
  )
621
  return path, refined, gr.update(visible=bool(refined))
622
 
623
 
624
+ def _fill_lora_slots(files, *current):
625
+ """Drop `.safetensors` files on the uploader and their paths land in the first free slots, so a local adapter
626
+ needs no typing at all."""
627
+ slots = list(current)
628
+ for path in files or []:
629
+ for index, value in enumerate(slots):
630
+ if not (value or "").strip():
631
+ slots[index] = path
632
+ break
633
+ return [gr.update(value=value) for value in slots]
634
+
635
+
636
  load_models()
637
 
638
  INTRO = """# MiniMax-H3 Reference
 
648
  reference.
649
  """
650
 
651
+ LORA_HELP = """Each slot takes a Hugging Face repo (`owner/repo`), a file inside one
652
+ (`owner/repo/name.safetensors`), a file URL, or a local path — or just drop the files below. A strength of `0`
653
+ switches a slot off without clearing it. Adapters have to be trained against the `transformer_ref/` partition.
654
+ """
655
+
656
  CSS = """
657
  .main.fillable { max-width: 1250px !important; }
658
  .dark .gradio-container { color: var(--body-text-color); }
 
693
  with gr.Tab("Video"):
694
  video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
695
  run = gr.Button("Generate", variant="primary")
696
+
697
+ with gr.Accordion("LoRA", open=False):
698
+ gr.Markdown(LORA_HELP)
699
+ lora_references, lora_scales = [], []
700
+ for slot in range(LORA_SLOTS):
701
+ with gr.Row():
702
+ lora_references.append(
703
+ gr.Textbox(label=f"LoRA {slot + 1}", placeholder="owner/repo", scale=3)
704
+ )
705
+ lora_scales.append(
706
+ gr.Slider(
707
+ label="Strength",
708
+ minimum=LORA_MIN_SCALE,
709
+ maximum=LORA_MAX_SCALE,
710
+ step=0.05,
711
+ value=1.0,
712
+ scale=2,
713
+ )
714
+ )
715
+ lora_upload = gr.File(
716
+ label="Drop .safetensors here to fill the slots",
717
+ file_count="multiple",
718
+ file_types=[".safetensors"],
719
+ type="filepath",
720
+ )
721
+
722
  with gr.Accordion("Advanced options", open=False):
723
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
724
  match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
 
751
  duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
752
  )
753
 
754
+ # `reference, strength, reference, strength, ...`, which is how `generate` unpacks them.
755
+ lora_inputs = [field for pair in zip(lora_references, lora_scales) for field in pair]
756
+ lora_upload.upload(_fill_lora_slots, [lora_upload, *lora_references], lora_references, api_name=False)
757
+
758
+ # Same order as `generate`'s signature: the exampled five first, then the remaining image slots, then the LoRA
759
+ # fields the `*lora_fields` tail collects.
760
+ request = [
761
+ prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample, *lora_inputs
762
+ ]
763
 
764
  gr.Examples(
765
  examples=[