Building an AI video interface looks simple in a demo: add a prompt box, an upload button, and a Generate button.
The real product work starts when those inputs mean different things.
While building MiniMaxH3.app, an independent third-party studio around the MiniMax H3 open-weight video model, we found that text-to-video, first/last frame, and multi-reference generation should not be treated as cosmetic tabs over the same form. Each workflow has a different input contract, a different failure surface, and a different definition of "control."
This post explains the implementation decisions behind those three workflows: how we normalize user intent, validate media before upload, preserve reference order, expose task state, and reserve credits without charging for failed jobs.
MiniMaxH3.app is an independent third-party project. It is not affiliated with MiniMax or Hailuo AI, and it does not distribute model weights.
Start with the user's evidence, not the provider mode
Internally, our UI exposes three concepts:
type GeneratorMode = "t2v" | "flf" | "omni";
Enter fullscreen mode Exit fullscreen mode
They describe what the user is providing:
- Text to Video: an instruction only
- First / Last Frame: one or two ordered visual endpoints
- Omni Reference: a package of images, video, and/or audio
That distinction is more useful than exposing raw provider terminology. A creator does not care whether a backend calls a request i2v or r2v; they care whether the first image is a starting frame, whether a video defines motion, and whether an audio clip can set timing.
We translate the UI choice into an effective request mode only when generation begins:
const effectiveMode =
mode === "omni"
? "r2v"
: mode === "flf" && (firstFrame || lastFrame)
? "i2v"
: "t2v";
Enter fullscreen mode Exit fullscreen mode
The fallback is deliberate. If someone opens First / Last Frame but uploads no frame, the request is still valid text-to-video. The interface explains that fallback instead of producing an unexplained validation error.
This gives us a useful design rule:
Workflow labels should describe the creative evidence a user supplies; provider modes should remain an implementation detail.
Workflow 1: text-to-video is mostly a prompting contract
Text-to-video has the smallest upload surface, but it still benefits from explicit constraints.
Our current studio accepts prompts up to 7,000 characters, durations from 4 to 15 seconds, and six aspect ratios:
const PROMPT_MAX = 7000;
const RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
const DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const;
Enter fullscreen mode Exit fullscreen mode
The long prompt limit is not an invitation to add adjectives indefinitely. It allows a prompt to behave like a compact shot brief: subject, action, camera, light, pacing, dialogue, and sound.
From a product perspective, the important choices are:
- reject an empty prompt before any network request;
- make output duration visible because it affects both cost and waiting time;
- keep aspect ratio choices finite rather than accepting arbitrary strings;
- treat sound as part of the prompt, because the output includes stereo audio in the same generation.
If you want to inspect the live control surface, the MiniMax H3 video generator exposes the exact options discussed here.
Workflow 2: first and last frames make ordering part of the API
An image-to-video upload is not just a set of files. In a first/last-frame workflow, order is semantic.
The first image defines the opening state. The second image defines the destination. Reversing them changes the requested motion, even if the file set is identical.
We therefore keep separate firstFrame and lastFrame state, build an ordered upload list, and then map the returned URLs back to their roles:
const frameItems = [firstFrame, lastFrame].filter(Boolean);
const frameUrls = await uploadVideoAssetsInOrder(frameItems, batchId);
let index = 0;
if (firstFrame) imageStartUrl = frameUrls[index++];
if (lastFrame) imageEndUrl = frameUrls[index];
Enter fullscreen mode Exit fullscreen mode
This is safer than uploading in parallel and relying on completion order.
Validation also happens before upload. The current input contract accepts JPG, PNG, WebP, HEIC, and HEIF images up to 30 MB each. Each side must be between 256 and 5,760 pixels, and the aspect ratio must stay between 5:2 and 2:5.
Those checks are not decorative UI copy. Failing early avoids three bad outcomes:
- uploading a large asset that will later be rejected;
- reserving credits for a request that cannot be created;
- returning a generic provider error that gives the user no repair path.
The output follows the uploaded image ratio, so we hide the manual aspect-ratio decision once a valid frame anchors the request. The dedicated First / Last Frame workflow shows that behavior.
Workflow 3: omni reference needs a media budget
Multi-reference generation changes the interface from “describe the scene” to “assign roles to evidence.”
Our Omni Reference mode accepts:
- up to 9 images;
- up to 3 video clips;
- up to 3 audio clips;
- no more than 12 assets total.
Video and audio clips can each run from 2 to 15 seconds, with a 15-second total budget per media type. At least one asset is required, but audio can be used without an image or video.
The limits are represented as data rather than scattered conditional branches:
const OMNI_LIMITS = {
images: 9,
videos: 3,
audios: 3,
total: 12,
};
const CLIP_MIN_SECONDS = 2;
const CLIP_MAX_SECONDS = 15;
const TYPE_TOTAL_SECONDS = 15;
Enter fullscreen mode Exit fullscreen mode
The UI disables an upload control as soon as either its per-type cap or the overall cap is reached. That prevents a confusing state where the user can select a file only to have it rejected afterward.
We also keep image, video, and audio arrays separate. This makes validation and display simpler, while an ordered upload helper groups the resulting URLs for the request payload:
const entries = [
...omniImages.map(asset => ({ asset, kind: "images" })),
...omniVideos.map(asset => ({ asset, kind: "videos" })),
...omniAudios.map(asset => ({ asset, kind: "audios" })),
];
const { images, videos, audios } =
await uploadVideoAssetsInOrder(entries, batchId);
Enter fullscreen mode Exit fullscreen mode
The key UX lesson is that “supports many files” is not enough. Users need to know what each reference is supposed to control. We prompt them to refer to assets explicitly as Image 1, Video 1, or Audio 1, and to describe whether an asset defines identity, product design, motion, camera rhythm, voice, or atmosphere.
You can see the resulting constraints in the live Omni Reference video workflow.
Separate uploading from task creation
Media workflows fail in more places than text-only requests. A useful state model needs to distinguish at least:
type SubmitPhase = "idle" | "uploading" | "creating";
const ACTIVE_TASK_STATUSES = [
"created",
"queued",
"running",
"persisting",
] as const;
Enter fullscreen mode Exit fullscreen mode
“Uploading 3 of 7 assets” requires a different user response from “the provider is rendering your video.” Combining both under a single loading spinner makes a slow upload look like a stalled generation.
Partial uploads are another edge case. If the fourth asset fails after three successful uploads, we collect the URLs already created and request their deletion before surfacing the error. That keeps failed requests from leaving unreferenced storage objects behind.
try {
// upload assets in deterministic order
} catch (error) {
const partialUrls = error.uploadedUrls ?? [];
if (partialUrls.length > 0) {
await deleteUploadedAssets([...new Set(partialUrls)]);
}
throw error;
}
Enter fullscreen mode Exit fullscreen mode
This cleanup path is easy to skip in a prototype and expensive to retrofit after real users start uploading 30–50 MB media files.
Reserve credits, then settle against task outcome
Generation cost is based on output duration: 20 credits per output second in the current studio. A five-second request therefore reserves 100 credits.
We reserve before calling the provider so that two simultaneous requests cannot both spend the same balance. The task then owns the reservation while it moves through the state machine.
If the provider reports failure and the task still has credits_status === "reserved", the task endpoint refunds the reservation. Successful tasks settle the credits instead.
The important property is idempotency: polling the same failed task multiple times must not issue multiple refunds.
This is why “failed generations are refunded” is not only pricing copy. It is an invariant tied to task state.
Expose constraints before the user pays for them
The most reusable lesson from this build is that good AI UX often means exposing constraints earlier, not hiding them.
We show:
- prompt, media-count, file-size, dimension, and duration limits before submission;
- the effective workflow when a mode falls back;
- upload progress separately from generation progress;
- the credit reservation implied by duration;
- task states that distinguish queued, running, persisting, succeeded, and failed;
- automatic refund status for failed tasks.
This reduces support work, but it also improves creative results. A creator who understands the role and budget of each reference is more likely to build a coherent request.
For concrete prompts, source assets, and output context, we maintain a public MiniMax H3 examples and prompts library. It is more useful for debugging than a highlight reel because each example preserves the inputs behind the output.
Final architecture rule
The three workflows can be summarized by the evidence they provide:
- text gives the system an instruction;
- first and last frames provide ordered visual endpoints;
- omni references provide a typed package of identity, style, motion, and sound.
Once those contracts are explicit, the rest of the application becomes easier to reason about: validation, upload ordering, provider payloads, progress states, and credit handling all follow from the workflow instead of accumulating as special cases around one oversized form.
That is the architecture we would keep even if the underlying provider changed.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.