FLUX 3 Max is a fast video generation model by Black Forest Labs (a latency-optimized variant of FLUX 3 Video). Its keyframes-to-video mode lets you pin 1 to 10 images to exact positions in a 5 to 20 second clip and have the model generate the motion between them. This post is about that mode only: how positions are counted, which requests get rejected and why, and a small Node.js helper that turns a list of images into valid keyframes.
Disclosure: I work on apimodels.app, a multi-model API where FLUX 3 Max is available as flux-3-max. The request shapes and error messages below are the ones that API returns; I tested them on 20 October 2026.
Positions are frame numbers at 24 fps
A keyframe is an image plus a position, and the position is a frame number in the 24 fps output, not a timestamp:
{ "image_url": "https://example.com/a.jpg", "frame_index": 72 }
The rules that follow from that:
- Frame
0is the first frame of the video. - The last frame is
duration × 24. A 5-second clip ends at frame 120, a 10-second clip at 240, the 20-second maximum at 480. - A position must be a whole number in that range, and two keyframes cannot share one.
- Order in the array does not matter; they are sorted by position before generation.
Converting from seconds is Math.round(seconds × 24), so 2.5 s is frame 60 and 3 s is frame 72. apimodels.app accepts { image_url, time } with time in seconds and does that conversion for you, which is handy when your storyboard is written in seconds. You can mix both forms in one array.
Keyframes replace first and last frame
FLUX 3 Max has a separate image-to-video mode driven by first_frame_url and an optional last_frame_url. The two modes do not combine: if a request carries both a keyframes array and a first or last frame, the API returns 400 rather than guessing which one you meant.
To lock both ends and something in the middle, put all of them in keyframes:
"keyframes": [
{ "image_url": "https://example.com/start.jpg", "time": 0 },
{ "image_url": "https://example.com/middle.jpg", "time": 3 },
{ "image_url": "https://example.com/end.jpg", "time": 6 }
]
with "duration": 6, so the last image sits on frame 144, the final frame.
The 400s you will meet
Every one of these is checked before the task is created, so a rejected request costs nothing. The messages are what apimodels.app returns:
| You sent | Message |
|---|---|
keyframes: [] |
keyframes must be a non-empty array of {image_url, frame_index} (or {image_url, time} in seconds) |
| 11 or more keyframes | keyframes accepts at most 10 images (got 11) |
time: 7 with duration: 6
|
keyframes[2] frame position must be a whole frame from 0 to 144 (duration × 24 fps; got 7) |
| Two keyframes at frame 72 | keyframes[1] repeats frame 72; each keyframe needs its own position |
| A keyframe with no position | keyframes[0] needs frame_index (24 fps frame number) or time (seconds) |
keyframes plus first_frame_url
|
Send either keyframes or first_frame_url / last_frame_url, not both |
Several images in images
|
takes one first frame (plus an optional last frame) or a keyframes array, not 3 reference images |
The out-of-range one is the most common: you shorten duration from 10 to 6 and forget that a keyframe at 8 seconds no longer fits. The fix is to compute positions from the duration instead of hardcoding them.
A helper that spaces images evenly
Most storyboards are "these images, in this order, evenly across the clip". This helper does that and stays inside the rules: first image on frame 0, last image on the final frame, the rest spread between them, no duplicates.
const FPS = 24;
// images: array of URLs in story order (1 to 10)
// duration: whole seconds, 5 to 20
function evenKeyframes(images, duration) {
if (images.length < 1 || images.length > 10) throw new Error("1 to 10 images");
const last = duration * FPS;
if (images.length === 1) return [{ image_url: images[0], frame_index: 0 }];
return images.map((url, i) => ({
image_url: url,
frame_index: Math.round((i * last) / (images.length - 1)),
}));
}
console.log(evenKeyframes(["a.jpg", "b.jpg", "c.jpg", "d.jpg"], 6));
// frames 0, 48, 96, 144
Even spacing is a starting point, not a rule. If a shot needs to linger, move its keyframe later; the model has more frames to work with on that stretch.
Full request with polling
The API is asynchronous: create a task, then poll the same endpoint with the task id (or pass callback_url to get the result POSTed to you).
const API = "https://api.apimodels.app/v1/video/generations";
const headers = {
Authorization: `Bearer ${process.env.APIMODELS_API_KEY}`,
"Content-Type": "application/json",
};
const duration = 6;
const create = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({
model: "flux-3-max",
prompt:
"A smooth cinematic transition from a casual kitchen snapshot to a premium " +
"studio product shot, light sweeps across the product",
duration,
resolution: "720p",
keyframes: evenKeyframes(
["https://example.com/start.jpg", "https://example.com/mid.jpg", "https://example.com/end.jpg"],
duration
),
generate_audio: false,
}),
}).then((r) => r.json());
if (create.code !== 200) throw new Error(create.msg); // the 400s above land here
let task;
do {
await new Promise((r) => setTimeout(r, 3000));
task = (await fetch(`${API}?task_id=${create.data.taskId}`, { headers }).then((r) => r.json())).data;
} while (task.state === "pending");
if (task.state === "failed") throw new Error(task.failMsg);
console.log(task.resultUrls[0]);
In my run (two square product images at 0 s and 5 s, 5 seconds, 720p, audio off, reasoning_level: "low") the video was ready 19 seconds after the create request, at 960×960. One run is a spot check, not a benchmark. With aspect_ratio left at auto the output follows the first image, so square inputs give a square video.
Prompting between keyframes
The keyframes fix what the frames look like; the prompt is where you describe the motion that connects them. A few things that help:
- Describe the transition, not the images. "Light sweeps across as the camera pushes in" tells the model something the images cannot.
- Keep subject, lighting and lens consistent across keyframe images when you want continuity. Very different images force a transition the model has to invent.
- Leave
generate_audioon and describe the sound ("soft whoosh, quiet room tone") if you want audio that follows the motion; turn it off if you will score the video yourself.
When keyframes are the wrong tool
- You want the opening image to drive the whole clip and do not care about intermediate frames: use plain image-to-video with
first_frame_url, which is a simpler request. - You need the same character across many separate shots, or more than 20 seconds in one pass: FLUX 3 Max takes at most 10 images and 20 seconds. Seedance 2.5 accepts up to 50 reference assets and 30 seconds.
- You need to transfer motion from a reference video: FLUX 3 Max does not accept reference videos.
Cost
On apimodels.app FLUX 3 Max is billed per second of output, and keyframes cost the same as the other modes: $0.221/s at 720p and $0.377/s at 1080p, so the 6-second 720p example is $1.33. Until 7 November 2026 (00:00 UTC) there is a special price of $0.085/s and $0.145/s. Failed generations and rejected requests are not charged.
Parameters, pricing and a comparison with Seedance 2.5, Kling V3 and Veo 3.1 are in the FLUX 3 Max API guide; the playground with a keyframe uploader is on the FLUX 3 Max model page.
Written with AI assistance for structure and wording; the code, error messages and timing come from my own test runs.
Top comments (0)