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:
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,
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.