MiniMax H3
Video with its own stereo audio from one denoising pass, at 768p. This is H3-Base, the part MiniMax published. The context stage their own workflow runs in front of it is not open, so results here are the base model's rather than what their API returns.
What runs
This is the whole app. The entry names the model and what serves it, and the build turns that into these files: there is nothing else to write.
"""MiniMax H3, generated from a declarative template."""
from __future__ import annotations
import base64
import tempfile
from pydantic import BaseModel, ConfigDict, Field
from runware_serverless import serve
MODEL = "MiniMaxAI/MiniMax-H3"
REVISION = "42ed227ee7df40d41602854ae760620d6eb651fe"
CACHE = "/weights"
FPS = 24
DEFAULT_FRAMES = 124
class Request(BaseModel):
# An input this model does not read is a line the caller believed was doing
# something, so it is refused rather than dropped. Without this, pydantic
# ignores it: a width sent to a model of fixed size, or a misspelled seed,
# both come back as a result that silently answered a different request.
model_config = ConfigDict(extra="forbid")
prompt: str = Field(description="What to generate, as video with its own audio.")
frames: int = Field(
default=DEFAULT_FRAMES,
ge=120,
le=360,
description="At 24 fps, so 120 frames is 5 seconds and 360 is 15.",
)
seed: int | None = Field(default=None, description="Repeat a result by repeating its seed.")
class Generated(BaseModel):
video: str = Field(description="The result as a base64 MP4, with its audio muxed in.")
frames: int
seed: int | None
@serve
class GeneratedVideo:
def load(self) -> None:
if hasattr(self, "pipeline"):
return
# Imported here rather than at the top of the file. The build imports
# this file with the platform's own dependencies, so nothing above
# exists until a worker runs.
import torch
from diffusers import ModularPipeline
self.torch = torch
self.pipeline = ModularPipeline.from_pretrained(
MODEL, revision=REVISION, cache_dir=CACHE
)
from diffusers import MiniMaxH3Transformer3DModel, TorchAoConfig
from transformers import Qwen3VLForConditionalGeneration
from transformers import TorchAoConfig as TransformersTorchAoConfig
from torchao.quantization import Int8WeightOnlyConfig
self.pipeline.update_components(
transformer=MiniMaxH3Transformer3DModel.from_pretrained(
MODEL,
subfolder="transformer",
revision=REVISION,
cache_dir=CACHE,
dtype=torch.bfloat16,
device_map="cuda",
quantization_config=TorchAoConfig(
Int8WeightOnlyConfig(version=2),
modules_to_not_convert=[
"proj_in",
"audio_proj_in",
"context_embedder",
"time_embedder",
"time_proj",
"token_refiner",
"norm_out",
"proj_out",
"audio_proj_out",
],
),
),
text_encoder=Qwen3VLForConditionalGeneration.from_pretrained(
MODEL,
subfolder="text_encoder",
revision=REVISION,
cache_dir=CACHE,
dtype=torch.bfloat16,
device_map="cuda",
quantization_config=TransformersTorchAoConfig(
Int8WeightOnlyConfig(version=2),
modules_to_not_convert=[
"model.visual",
"model.language_model.embed_tokens",
"model.language_model.norm",
"lm_head",
],
),
),
)
# Every component stays on the card. A worker's host RAM is half its
# card's VRAM, 48 GiB against 96 GiB here, so offloading to the host
# runs out of memory long before the card does: the kill then comes
# from the container's cgroup as exit 137, not from torch as a CUDA
# error, which is why it reads as a platform fault rather than a
# sizing one.
self.pipeline.load_components(
workflow="fl2va", dtype=torch.bfloat16, device_map="cuda"
)
def predict(self, request: Request) -> Generated:
from diffusers.utils.export_utils import encode_video
generator = None
if request.seed is not None:
generator = self.torch.Generator().manual_seed(request.seed)
results = self.pipeline(
prompt=request.prompt,
num_frames=request.frames,
generator=generator,
output=["videos", "audio", "sampling_rate"],
)
videos, audio, sampling_rate = _named(results)
# Both are indexed, because the pipeline returns a batch and the encoder
# takes one clip. Dropping the batch dimension on the audio matters more
# than it looks: the encoder treats any shape carrying a 1 as mono and
# flattens it, so a stereo [1, 2, N] becomes one interleaved channel of
# 2N and the track plays at twice the length of the picture.
clip, track = videos[0], audio[0]
with tempfile.NamedTemporaryFile(suffix=".mp4") as handle:
encode_video(
clip,
fps=FPS,
output_path=handle.name,
audio=track,
audio_sample_rate=sampling_rate,
)
handle.seek(0)
encoded = base64.b64encode(handle.read()).decode("ascii")
# What came out, not what was asked for. The model rounds the frame
# count to its own stride, so the two differ.
return Generated(video=encoded, frames=len(clip), seed=request.seed)
def _named(results: object) -> tuple:
"""The named outputs, however the pipeline hands them back."""
if isinstance(results, dict):
return results["videos"], results["audio"], results["sampling_rate"]
if hasattr(results, "videos"):
return results.videos, results.audio, results.sampling_rate
return tuple(results)configVersion: 1
id: minimax-h3
name: MiniMax H3
source:
type: code
entry: model.py
compute:
gpuType: rtx-pro-6000
build:
requirements:
- torch==2.14.1
- torchvision==0.29.1
- diffusers==0.42.1
- transformers==5.21.0
- accelerate==1.15.0
- av==16.0.1
- torchao==0.18.0
volumes:
- /weightsDeploying
Deploy it as it is, or take the files above and deploy your own copy. Nothing you change reaches back to this page.
runware serverless deploy --from https://runware.ai/docs/serverless/examples/minimax-h3.ziprunware serverless deploy model.py \
--id minimax-h3 \
--gpu-type rtx-pro-6000 \
--requirement torch==2.14.1 \
--requirement torchvision==0.29.1 \
--requirement diffusers==0.42.1 \
--requirement transformers==5.21.0 \
--requirement accelerate==1.15.0 \
--requirement av==16.0.1 \
--requirement torchao==0.18.0 \
--volume /weightsCalling it
One request that works, against the app you just deployed. The fields are the ones the entry declares above, and anything it does not declare is refused rather than ignored.
import { createClient } from '@runware/sdk'
const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })
const task = await client.invoke({
appId: 'minimax-h3',
endpointPath: 'predict',
payload: {
request: {
prompt: 'A paper boat drifting down a rain gutter, close up'
}
}
})import asyncio
import os
from runware import Runware
async def main():
client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
try:
task = await client.invoke(
"minimax-h3",
"predict",
{
"request": {
"prompt": "A paper boat drifting down a rain gutter, close up"
}
},
)
finally:
await client.close()
asyncio.run(main())curl https://api.serverless.runware.ai/v1/apps/minimax-h3/invoke-async/predict \
-H "Authorization: Bearer $RUNWARE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"request": {
"prompt": "A paper boat drifting down a rain gutter, close up"
}
}
}'cat <<'EOF' | runware serverless apps invoke minimax-h3 predict -f -
{
"request": {
"prompt": "A paper boat drifting down a rain gutter, close up"
}
}
EOF{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"request": {
"prompt": "A paper boat drifting down a rain gutter, close up"
}
}
}What it costs
This entry is pinned to rtx-pro-6000, which is the card it was tested on rather than the only one it runs on: --gpu-typedeploys it somewhere else. You pay for that card by the second while a worker holds it, and for nothing else. Pricing covers what meters, and the rates are published per GPU-second.