Spaces:
Running on Zero
Running on Zero
Cache the conditioner client with functools.cache, tidy comments
Browse files- README.md +11 -18
- app.py +14 -42
- h3_aoti.py +2 -3
- 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 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.
|
| 195 |
-
|
| 196 |
-
(
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
That PR is a WIP
|
| 214 |
-
classes to cut the pipeline in two — has to be re-checked against the new head at the same time.
|
| 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.
|
|
|
|
|
|
|
| 311 |
|
| 312 |
-
|
| 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
|
| 360 |
-
|
| 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`
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 66 |
-
#
|
| 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
|
| 4 |
-
|
| 5 |
|
| 6 |
before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
|
| 7 |
|
| 8 |
-
where `before_encode`, `text_encoder`, `vae_encoder` and `denoise`
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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
|
| 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
|
| 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
|