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.

Video generationrtx-pro-6000MiniMaxMiniMax licenseMiniMaxAI/MiniMax-H3

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.

model.py
"""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)
runware.yaml
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:
  - /weights

Deploying

Deploy it as it is, or take the files above and deploy your own copy. Nothing you change reaches back to this page.

minimax-h3rtx-pro-6000source
From a URL
runware serverless deploy --from https://runware.ai/docs/serverless/examples/minimax-h3.zip
From a clone
runware 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 /weights

Calling 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.