multimodalart HF Staff commited on
Commit
1f48523
·
verified ·
1 Parent(s): b2bc645

Cache the conditioner client with functools.cache, tidy comments

Browse files
Files changed (4) hide show
  1. README.md +11 -18
  2. app.py +14 -42
  3. h3_aoti.py +2 -3
  4. h3_split_blocks.py +16 -29
README.md CHANGED
@@ -184,18 +184,14 @@ on is cold and a cold one pays the lazy 72.16 GiB `PIPE.to("cuda")` inside its f
184
  ## Whose GPU quota pays
185
 
186
  Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the
187
- **requesting user**, with nothing in this repository arranging it. `gradio_client` attaches the caller's own
188
- `x-ip-token` to every outgoing call by itself, reading it off gradio's `LocalContext` inside the event listener
189
- (`Client.send_data` -> `add_zero_gpu_headers`), and ZeroGPU's `/schedule` charges the booking to whatever that token
190
- identifies. Forwarding the header by hand is not needed and is actively worse: a cached `Client` would pin one stale
191
- token, which ZeroGPU refuses with `Expired ZeroGPU proxy token`.
192
 
193
  A caller with no token to forward — a `gradio_client` script rather than a browser — leaves the conditioner's booking
194
- attributed to this Space's pod IP and its small shared quota. That path is why the conditioner books small: an
195
- unattributed caller may book at most 120 credits at a time and an `xlarge` booking costs **twice** its seconds
196
- (`_gpu_size_units`), so the conditioner books the encode (45 s) and a prompt upsample (60 s) as two separate calls,
197
- where one combined booking of the old 300 s would be — and was — refused outright with `The requested GPU duration
198
- (600s) is larger than the maximum allowed`.
199
 
200
  ## Secrets
201
 
@@ -207,14 +203,11 @@ is a public Space called on the requesting user's own ZeroGPU token, never on an
207
  ## Where diffusers comes from
208
 
209
  MiniMax-H3 is modular-only and not in a released `diffusers`, so `requirements.txt` installs it from the canonical
210
- pull request, [huggingface/diffusers#14371](https://github.com/huggingface/diffusers/pull/14371), pinned to the
211
- **commit** `665f5782` (`refs/pull/14371/head` at deploy time) rather than to the moving `minimax-h3-refactor` branch.
212
-
213
- That PR is a WIP: it needs **re-pinning whenever it updates**, and `h3_split_blocks.py` — which subclasses its block
214
- classes to cut the pipeline in two — has to be re-checked against the new head at the same time. The PR refactored
215
- the blocks into one workflow-selected pipeline (per-modality reference classes, `before_encode` / `after_denoise`
216
- steps, no `packing` modules), so block names and the shape of the split are exactly what a new head is liable to
217
- move.
218
 
219
  Two of those are `ref2va`-only and easy to miss. PyAV decodes a reference video or audio file as the reference is
220
  built, and **`torchaudio`** resamples a soundtrack that is not already at the audio VAE's 32 kHz — a 32 kHz
 
184
  ## Whose GPU quota pays
185
 
186
  Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the
187
+ requesting user, with nothing here arranging it: `gradio_client` attaches the caller's own `x-ip-token` to every
188
+ outgoing call, reading it off gradio's `LocalContext` inside the event listener (`Client.send_data` ->
189
+ `add_zero_gpu_headers`), and ZeroGPU charges the booking to whatever that token identifies.
 
 
190
 
191
  A caller with no token to forward — a `gradio_client` script rather than a browser — leaves the conditioner's booking
192
+ attributed to this Space's pod IP and its small shared quota. An unattributed caller may book at most 120 credits at a
193
+ time and an `xlarge` booking costs twice its seconds, so the conditioner books the encode (45 s) and a prompt upsample
194
+ (60 s) as two separate calls, each within that ceiling.
 
 
195
 
196
  ## Secrets
197
 
 
203
  ## Where diffusers comes from
204
 
205
  MiniMax-H3 is modular-only and not in a released `diffusers`, so `requirements.txt` installs it from the canonical
206
+ pull request, [huggingface/diffusers#14371](https://github.com/huggingface/diffusers/pull/14371), pinned to the commit
207
+ `665f5782` (`refs/pull/14371/head`) rather than to the moving `minimax-h3-refactor` branch.
208
+
209
+ That PR is a WIP, so it needs re-pinning whenever it updates, and `h3_split_blocks.py` — which subclasses its block
210
+ classes to cut the pipeline in two — has to be re-checked against the new head at the same time.
 
 
 
211
 
212
  Two of those are `ref2va`-only and easy to miss. PyAV decodes a reference video or audio file as the reference is
213
  built, and **`torchaudio`** resamples a soundtrack that is not already at the audio VAE's 32 kHz — a 32 kHz
app.py CHANGED
@@ -21,6 +21,7 @@ import os
21
  import tempfile
22
  import time
23
  import traceback
 
24
 
25
  # First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
26
  # 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
@@ -200,7 +201,6 @@ def get_duration(prompt_embeds, text_token_tags, references, height, width, num_
200
  PIPE = None
201
  MANAGER = None
202
  LOAD_ERROR: str | None = None
203
- CLIENT = None
204
 
205
 
206
  def load_models() -> str | None:
@@ -237,8 +237,6 @@ def load_models() -> str | None:
237
  blocks = MiniMaxH3Ref2VAGeneratorBlocks()
238
  print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
239
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
240
- # Every repository this Space reads is public — the checkpoint, the AoTI packages and the conditioner
241
- # Space — so no token is passed anywhere.
242
  pipe.load_components(dtype=torch.bfloat16)
243
 
244
  # Pin the two autoencoders to torch SDPA *before* the transformer takes cuDNN, and in that order.
@@ -306,35 +304,13 @@ def _arm_decode_hooks(pipe):
306
  setattr(module, method, armed)
307
 
308
 
 
309
  def conditioner():
310
- """The other half, over the gradio API. Cached — building a `Client` costs a round trip to the Space config.
 
 
311
 
312
- No token is passed and none has to be: `gradio_client` attaches the caller's own ZeroGPU token itself, per call,
313
- by reading the `x-ip-token` of the request being served off gradio's `LocalContext` (`Client.send_data` ->
314
- `add_zero_gpu_headers`). So calling this from inside an event listener — which is the only place it is called —
315
- bills the conditioner's booking to the user who asked for the video, exactly as this Space's own booking is, and
316
- forwarding the header by hand would only pin a stale token onto a cached client.
317
- """
318
- global CLIENT
319
- if CLIENT is None:
320
- from gradio_client import Client
321
-
322
- CLIENT = Client(CONDITIONER_SPACE)
323
- return CLIENT
324
-
325
- def probe(path: str) -> tuple[float | None, float | None]:
326
- """`(video seconds, audio seconds)` of a media file, either being `None` when the stream is absent."""
327
- import av
328
-
329
- def seconds(stream, container):
330
- if stream.duration is not None and stream.time_base is not None:
331
- return float(stream.duration * stream.time_base)
332
- return None if container.duration is None else container.duration / av.time_base
333
-
334
- with av.open(path) as container:
335
- video = seconds(container.streams.video[0], container) if container.streams.video else None
336
- audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
337
- return video, audio
338
 
339
 
340
  def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
@@ -356,9 +332,8 @@ def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
356
  def build_references(references: list[tuple[str, str]]):
357
  """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order.
358
 
359
- One public class per modality since the blocks were refactored, each decoding its own file through `from_file`
360
- which is also what brings the rates along, a video its own frame rate and its soundtrack, a clip its sample rate.
361
- The blocks themselves never open a media file.
362
  """
363
  from diffusers.modular_pipelines.minimax_h3 import (
364
  MiniMaxH3AudioReference,
@@ -425,10 +400,10 @@ def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
425
  every image and every merged video frame pair, so the conditioner has to see them. It decodes the very same
426
  files this Space does, which is what keeps the two `setup` runs in agreement.
427
 
428
- `rewrite_prompt` is the conditioner's prompt upsampling: it rewrites the request into MiniMax-H3's trained
429
- reference format with its own Qwen3-VL which is shown the references, so it can name what each one contributes
430
- and encodes that instead, handing the rewrite back under the plan's `refined_prompt`. It runs on the conditioner's
431
- GPU booking, and this whole call happens before `_generate` books a card here, so `get_duration` is untouched.
432
  """
433
  from gradio_client import handle_file
434
  from safetensors import safe_open
@@ -628,8 +603,7 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
628
 
629
  with gr.Column():
630
  result = gr.Video(label="Video + soundtrack")
631
- # Only shown for a request that actually asked for a rewrite, so a plain request is not left with an
632
- # empty panel. The accordion is an output for that reason: its visibility is part of the answer.
633
  with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
634
  upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
635
 
@@ -651,8 +625,7 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
651
  )
652
 
653
  # Same order as `generate`'s signature: the exampled five first, then the remaining image slots. `upsample` is
654
- # appended after every input that was already here and every existing input keeps its position, so a positional
655
- # API client that predates it keeps working and simply takes the default.
656
  request = [prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample]
657
 
658
  gr.Examples(
@@ -686,7 +659,6 @@ with gr.Blocks(title="MiniMax-H3 Reference") as demo:
686
  cache_mode="lazy",
687
  )
688
 
689
- # The video stays the first output and the upsampled prompt is appended last, so existing consumers are untouched.
690
  run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
691
 
692
 
 
21
  import tempfile
22
  import time
23
  import traceback
24
+ from functools import cache
25
 
26
  # First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
27
  # 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
 
201
  PIPE = None
202
  MANAGER = None
203
  LOAD_ERROR: str | None = None
 
204
 
205
 
206
  def load_models() -> str | None:
 
237
  blocks = MiniMaxH3Ref2VAGeneratorBlocks()
238
  print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
239
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
 
 
240
  pipe.load_components(dtype=torch.bfloat16)
241
 
242
  # Pin the two autoencoders to torch SDPA *before* the transformer takes cuDNN, and in that order.
 
304
  setattr(module, method, armed)
305
 
306
 
307
+ @cache
308
  def conditioner():
309
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, off
310
+ gradio's `LocalContext`, so the conditioner's booking is billed to the user who asked for the video."""
311
+ from gradio_client import Client
312
 
313
+ return Client(CONDITIONER_SPACE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
 
316
  def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
 
332
  def build_references(references: list[tuple[str, str]]):
333
  """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order.
334
 
335
+ One class per modality, each decoding its own file through `from_file`, which brings the rates along: a video its
336
+ own frame rate and its soundtrack, a clip its sample rate. The blocks themselves never open a media file.
 
337
  """
338
  from diffusers.modular_pipelines.minimax_h3 import (
339
  MiniMaxH3AudioReference,
 
400
  every image and every merged video frame pair, so the conditioner has to see them. It decodes the very same
401
  files this Space does, which is what keeps the two `setup` runs in agreement.
402
 
403
+ `rewrite_prompt` asks the conditioner to rewrite the request into MiniMax-H3's trained reference format with its
404
+ own Qwen3-VL, which is shown the references so it can name what each one contributes, and encode that instead. It
405
+ runs on the conditioner's booking, and this call happens before `_generate` books a card here, so `get_duration`
406
+ is unaffected.
407
  """
408
  from gradio_client import handle_file
409
  from safetensors import safe_open
 
603
 
604
  with gr.Column():
605
  result = gr.Video(label="Video + soundtrack")
606
+ # An output, so it can be revealed only for a request that asked for a rewrite.
 
607
  with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
608
  upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
609
 
 
625
  )
626
 
627
  # Same order as `generate`'s signature: the exampled five first, then the remaining image slots. `upsample` is
628
+ # last and defaults off, so a positional API client that predates it is unaffected.
 
629
  request = [prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample]
630
 
631
  gr.Examples(
 
659
  cache_mode="lazy",
660
  )
661
 
 
662
  run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
663
 
664
 
h3_aoti.py CHANGED
@@ -62,9 +62,8 @@ import os
62
  from pathlib import Path
63
 
64
  AOTI = os.environ.get("H3_AOTI", "0") == "1"
65
- # A public **model** repo. It used to be a private dataset, which is why the repo type is still a variable: the
66
- # artifacts are keyed by quant/torch/arch under `<width>/torch<X.Y>/sm<cc>/<shape>` rather than laid out the way
67
- # `spaces.aoti_load` expects, so the download is done by hand either way (see `maybe_load`).
68
  AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
69
  AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
70
  # `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
 
62
  from pathlib import Path
63
 
64
  AOTI = os.environ.get("H3_AOTI", "0") == "1"
65
+ # The artifacts are keyed by quant/torch/arch under `<width>/torch<X.Y>/sm<cc>/<shape>` rather than laid out the way
66
+ # `spaces.aoti_load` expects, so the download is done by hand (see `maybe_load`).
 
67
  AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
68
  AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
69
  # `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
h3_split_blocks.py CHANGED
@@ -1,14 +1,13 @@
1
  """The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
2
 
3
- MiniMax-H3 is modular-only. Since https://github.com/huggingface/diffusers/pull/14371 the whole model is *one*
4
- `MiniMaxH3Blocks` sequence whose branches are picked per request (and per `workflow=`) from the inputs:
5
 
6
  before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
7
 
8
- where `before_encode`, `text_encoder`, `vae_encoder` and `denoise` are each an auto-block that switches on
9
- `references` (the `ref2va` workflow) versus the keyframe inputs (`t2va` / `fl2va`), and `denoise` is a whole
10
- sub-sequence — `prepare_layout -> prepare_latents -> set_timesteps -> denoise` against `transformer` or
11
- `transformer_ref`.
12
 
13
  The conditioner (a 62.14 GiB Qwen3-VL) and the denoiser (a 61.73 GiB transformer plus ~20.5 GiB of float32 VAEs) do
14
  not fit on one 95 GiB card unquantized, so this module cuts that sequence in two at the `text_encoder` step, once per
@@ -22,25 +21,15 @@ partition:
22
  * `MiniMaxH3Ref2VAConditionerBlocks` / `MiniMaxH3Ref2VAGeneratorBlocks` are the same cut through the `ref2va`
23
  branch, so one conditioner Space serves both partitions out of the weights it already holds.
24
 
25
- What the refactor moved, and what that means for the cut:
26
-
27
- * the old `MiniMaxH3SetupStep` is gone. Its keyframe half is now `MiniMaxH3ResizeStep` (wrapped in the conditional
28
- `MiniMaxH3AutoResizeStep`, skipped entirely for a text-only request) and its geometry half moved *into*
29
- `MiniMaxH3PrepareLayoutStep`, which lives on the denoising side. So the `t2va` / `fl2va` conditioner half no
30
- longer resolves `num_frames` at all — the caller does, with `align_num_frames`, which is the one line of
31
- arithmetic that used to come back in the plan.
32
- * `MiniMaxH3Ref2VASetupStep` survived and still resolves the canvas *and* the frame count, but `num_frames` is now
33
- required there: a request that leaves the duration to its single audio-bearing reference resolves it caller-side.
34
- * unpacking the denoised rows moved out of the decoders into `MiniMaxH3AfterDenoiseStep`, so the generating halves
35
- carry that step explicitly.
36
- * `model_name` is `"minimax-h3"` on every block now — the `"minimax-h3-ref2va"` pipeline mapping was dropped when
37
- the two blocksets became workflows of one pipeline.
38
-
39
- `resize` / `setup` run on both sides on purpose. They own no pretrained component (PIL, decoded media and
40
- arithmetic), they resolve the canvas and prepare the keyframes or normalize the references — which the conditioner
41
- needs to build its vision blocks and the generator needs to encode with the VAEs. Running them twice over the same
42
- inputs is deterministic; both conditioner halves return the resolved `height` / `width` / `num_frames` anyway, so the
43
- caller pins them explicitly on the generating half.
44
 
45
  Only *text* encoding is remote. `vae_encoder` / `reference_encoder` stay on the denoising side: they run the two
46
  autoencoders, which the conditioner Space does not hold.
@@ -68,8 +57,7 @@ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
68
  def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
69
  """The wire format of the split, plus the plan the caller pins on the generating half.
70
 
71
- `num_frames` is only declared by the `ref2va` half: its setup step is the one that still resolves the frame count,
72
- while the keyframe half's own resolution moved into the layout step, on the other side of the cut.
73
  """
74
  return [
75
  OutputParam.template("prompt_embeds"),
@@ -96,8 +84,7 @@ class MiniMaxH3ConditionerBlocks(SequentialPipelineBlocks):
96
  return (
97
  "The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
98
  "encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
99
- "denoising half consumes. The frame count is the caller's to align — that arithmetic now lives in the "
100
- "layout step, on the denoising side."
101
  )
102
 
103
  @property
 
1
  """The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
2
 
3
+ MiniMax-H3 is modular-only, and the whole model is one `MiniMaxH3Blocks` sequence whose branches are picked per
4
+ request and per `workflow=` from the inputs:
5
 
6
  before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
7
 
8
+ where `before_encode`, `text_encoder`, `vae_encoder` and `denoise` each switch on `references` (the `ref2va` workflow)
9
+ versus the keyframe inputs (`t2va` / `fl2va`), and `denoise` is itself `prepare_layout -> prepare_latents ->
10
+ set_timesteps -> denoise` against `transformer` or `transformer_ref`.
 
11
 
12
  The conditioner (a 62.14 GiB Qwen3-VL) and the denoiser (a 61.73 GiB transformer plus ~20.5 GiB of float32 VAEs) do
13
  not fit on one 95 GiB card unquantized, so this module cuts that sequence in two at the `text_encoder` step, once per
 
21
  * `MiniMaxH3Ref2VAConditionerBlocks` / `MiniMaxH3Ref2VAGeneratorBlocks` are the same cut through the `ref2va`
22
  branch, so one conditioner Space serves both partitions out of the weights it already holds.
23
 
24
+ `resize` / `setup` run on both sides on purpose. They own no pretrained component (PIL, decoded media and arithmetic),
25
+ they resolve the canvas and prepare the keyframes or normalize the references — which the conditioner needs to build
26
+ its vision blocks and the generator needs to encode with the VAEs. Running them twice over the same inputs is
27
+ deterministic; both conditioner halves return the resolved `height` / `width` / `num_frames` anyway, so the caller
28
+ pins them explicitly on the generating half.
29
+
30
+ Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` /
31
+ `fl2va` frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives in the layout step on the
32
+ denoising side of the cut. `ref2va` still resolves its own frame count, but requires one to be passed.
 
 
 
 
 
 
 
 
 
 
33
 
34
  Only *text* encoding is remote. `vae_encoder` / `reference_encoder` stay on the denoising side: they run the two
35
  autoencoders, which the conditioner Space does not hold.
 
57
  def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
58
  """The wire format of the split, plus the plan the caller pins on the generating half.
59
 
60
+ `num_frames` is declared by the `ref2va` half alone: it is the one whose setup step resolves a frame count.
 
61
  """
62
  return [
63
  OutputParam.template("prompt_embeds"),
 
84
  return (
85
  "The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
86
  "encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
87
+ "denoising half consumes. The frame count is the caller's to align."
 
88
  )
89
 
90
  @property