# The duration your video API accepts is not the duration it renders

> Source: <https://dev.to/whereisthisplace/the-duration-your-video-api-accepts-is-not-the-duration-it-renders-4j86>
> Published: 2026-08-21 15:38:33+00:00

A sequence I cut to a music bed was three frames out at the first transition,

nine at the second, and by the sixth segment nothing lined up with anything. I

had asked every generation for ten seconds. Every generation had returned a

file that was not ten seconds.

Nothing in the API said so. The request took `duration: 10`

, returned `200`

,

and produced an MP4 whose container duration was `8.708`

. No warning field, no

note in the response body, and — the part that actually cost me the afternoon —

no mention of it on the docs page I had read three times.

This is a general property of latent video models rather than a bug in one

provider, and once you know the shape of it you can handle it in about twenty

lines. Here is the shape.

A video diffusion model does not work on frames. It works on a compressed

latent tensor, and the compression is temporal as well as spatial: a causal 3D

autoencoder folds a run of input frames into a single latent frame.

Because the encoder is causal, the first frame is kept whole and everything

after it is compressed in groups. With a temporal stride of `s`

, a clip of `F`

frames becomes

```
latent_frames = (F - 1) / s + 1
```

which only divides evenly when `F ≡ 1 (mod s)`

. Frame counts that miss that

condition get padded or truncated, so implementations pick the nearest legal

count and render that instead.

Stack a second constraint on top — many of these models generate in fixed

blocks of latent frames rather than one at a time — and the set of renderable

lengths collapses into a short arithmetic progression:

```
F = head + block · n          n ∈ ℕ
```

Every legal duration is one of those `F`

values divided by the frame rate.

Nothing between them is reachable. `duration: 10`

is not a request. It is a

hint that gets snapped to a grid you were never shown.

Three separate problems, and only the first is obvious.

**The output is not the length you promised.** Your UI said 10s, the file is

8.708s, so your UI lied. Not by much, and not in a way anyone notices on one

clip.

**The error compounds.** Nobody makes one clip. They make six and cut them

together. Six segments each 1.3 seconds short is eight seconds of drift, which

is the difference between "cuts on the beat" and "re-render the sequence."

**Your cost estimate is wrong in the direction that generates tickets.** These

APIs bill per second of output. Estimate from the requested duration, let the

model render a longer legal block, and you have quoted one number and charged

another. Users find that one on their own.

The fix is to stop treating duration as a free variable at the edge of your

system. Resolve it to a legal value before anything is displayed, priced or

persisted.

```
type Grid = { fps: number; block: number; head: number; min: number; max: number };

/** Legal frame counts are head + block·n, clamped to the provider's range. */
export function legalFrames(g: Grid): number[] {
  const out: number[] = [];
  for (let n = 0; ; n++) {
    const f = g.head + g.block * n;
    if (f > g.max) break;
    if (f >= g.min) out.push(f);
  }
  return out;
}

export function quantise(seconds: number, g: Grid) {
  const target = Math.round(seconds * g.fps);
  const frames = legalFrames(g).reduce((best, f) =>
    Math.abs(f - target) < Math.abs(best - target) ? f : best,
  );
  return { requested: seconds, frames, seconds: frames / g.fps };
}
```

Then three rules for the resolved value:

**Show it instead of the requested one.** The number in the duration control

should change to the number you are going to get, at the moment the user picks

it. A slider that snaps is honest. A slider that accepts anything and rounds

in private is not.

**Price it.** `frames / fps × rate`

, from the resolved frames. Never from the

requested seconds.

**Store it on the job.** When somebody asks why their six clips do not add up,

you want the answer in a column, not in a reconstruction.

The bug was reachable from an integration test I had simply not thought to

write:

``` js
it("returns the duration it promised", async () => {
  for (const requested of [4, 5, 6, 7, 8, 9, 10]) {
    const job  = await client.create({ prompt: "a still grey card", duration: requested });
    const meta = await ffprobe(await client.download(job.id));
    expect(meta.duration).toBeCloseTo(job.resolvedDuration, 2);
  }
});
```

Note what it asserts. Not that the file matches the **request** — that is a

test you cannot pass and should not want to — but that it matches what the API

**told you it resolved to**. That is a contract you can hold a provider to, and

if the provider returns no resolved value at all, the absence is itself the

finding.

Running the loop is also the cheapest way to discover the grid empirically.

Seven requests, `ffprobe`

on each, and you have the progression whether or not

anyone documented it.

For anyone about to build on one of these APIs:

That last point is not hypothetical. On the model I work with daily, the block

arithmetic is tight enough that across its entire published 4-to-15 second

range precisely one setting comes out even — and it is not a number anyone

would think to type. Someone

[worked the progression out frame by frame and showed why only one lands on a whole second](https://minimax-h3ai.video/blog/192-frames-is-the-only-whole-second),

which is ten minutes well spent if you are about to pick a default your users

will inherit.

The general lesson is smaller than the arithmetic: **when a generative API
accepts a continuous parameter the model can only satisfy discretely, the
rounding is part of the contract.** Ask where the grid is before you let a

*I help run minimax-h3ai.video, an independent
third-party interface for MiniMax H3. Not affiliated with MiniMax. Everything
above is checked against the published docs and linked where it isn't.*
