DEV Community

Super Lewis
Super Lewis

Posted on

Animated GIFs and markup-guided edits with the GPT Image 2.6 API — real timings and per-call cost

Our first GPT Image 2.6 GIF request asked for six frames at 1024×1024 and was rejected before a single frame was drawn. The prompt was fine. The model lays every frame out on one sheet, and that sheet would have been 4096 px on its long side, 256 px over the limit.

GPT Image 2.6 is OpenAI's November 2026 image model for ChatGPT, Codex and the API, offered to developers in two tiers: GPT-Image-2.6 Flare for speed and everyday work, and GPT-Image-2.6 Sunburst for quality, detail and consistency across edits. This tutorial uses its API to make a six-frame animated GIF, size the frames so the request is accepted, guide an edit with a markup layer, run a second edit round, and read what each call cost, with the timings from our launch-day runs and what each call comes to at today's prices.

Disclosure: I work on apimodels.app, the API platform used for every test below. apimodels.app is a multi-model API gateway: one API key and one endpoint for 160 image, video, audio and language models. We ran all seven tests on its production API on 5 November 2026, the day GPT Image 2.6 went live there. An AI assistant helped turn our test notes into this post; every timing and cost comes from the requests themselves.

What OpenAI announced

OpenAI's claims for Images 2.6, from OpenAI's Images 2.6 announcement, are about 50% faster generation, text-to-GIF, steadier multi-round edits of people, products and brand details, ChatGPT's image markup in the API, SVG export, and a new setting OpenAI calls "complexity" that controls how much the model plans the scene before drawing. OpenAI says Flare can return an image in about 6 seconds at low quality. The timings in this post are what our own requests took on launch day and in a re-test that evening; they are not a benchmark of OpenAI's figure.

What you need

  • Node.js 18 or later (built-in fetch), or just curl
  • sharp for drawing the markup layer in Step 3: npm install sharp
  • An apimodels.app API key in the APIMODELS_API_KEY environment variable
  • A place to host input images at a public URL (every example.com URL below is a placeholder)

How a request works: submit, then poll

GPT Image 2.6 requests on apimodels.app are asynchronous: you submit a job, get a task id, and poll until it finishes. POST https://api.apimodels.app/v1/images/generations returns { data: { taskId } }. You then GET the same path with ?task_id=... until data.state is "completed", and read the files from data.resultUrls and the charge from data.credits.

These are the parameters the steps below use:

  • model: gpt-image-2.6-flare or gpt-image-2.6-sunburst
  • quality: low, medium, high or auto (apimodels.app defaults to medium)
  • reasoning_effort: low, medium or high, which is the setting OpenAI calls "complexity" (any other value returns 400)
  • aspect_ratio + resolution (1K, 2K, 4K), or an exact image_size
  • output_format: png, jpeg, webp, gif or svg (svg is text-to-image only, one image per request)
  • frame_count (2–64) and frame_duration_ms (20–2000) for GIFs
  • image_urls (up to 16 references; any reference turns the request into an edit), plus markup_image_url or mask_url for guided edits

Every step reuses one small helper. It submits a body, polls every three seconds, and returns the result URLs with the elapsed time, the charge and the token breakdown:

// gpt26.mjs — example helper (Node.js 18+, no dependencies)
const ENDPOINT = 'https://api.apimodels.app/v1/images/generations'
const headers = {
  Authorization: `Bearer ${process.env.APIMODELS_API_KEY}`,
  'Content-Type': 'application/json',
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

export async function run(body, { timeoutMs = 5 * 60_000 } = {}) {
  const started = Date.now()
  const res = await fetch(ENDPOINT, { method: 'POST', headers, body: JSON.stringify(body) })
  const json = await res.json()
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(json)}`)
  const { taskId } = json.data

  while (Date.now() - started < timeoutMs) {
    await sleep(3000)
    const poll = await fetch(`${ENDPOINT}?task_id=${encodeURIComponent(taskId)}`, { headers })
    const { data } = await poll.json()
    if (data.state === 'completed') {
      const seconds = Math.round((Date.now() - started) / 1000)
      console.log(`${body.model}: ${seconds}s, $${data.credits}`)
      // usage is only present for png / jpeg / webp results, not for GIFs, markup or mask edits, or SVG
      return { urls: data.resultUrls, credits: Number(data.credits), usage: data.usage, seconds }
    }
    if (data.state === 'failed') throw new Error(`Task ${taskId} failed: ${JSON.stringify(data)}`)
  }
  throw new Error(`Task ${taskId} is still running; poll it again instead of resubmitting`)
}
Enter fullscreen mode Exit fullscreen mode

The timeout only stops your loop, not the job. If it fires, keep the task id and poll again later rather than submitting the same request twice.

Step 1: Request a six-frame GIF loop

A GIF is an ordinary image request with output_format: "gif", a frame count and a per-frame duration. The settings below match our mascot test (Flare, low quality, six frames at 150 ms each); the prompt text is illustrative. With curl:

curl -s https://api.apimodels.app/v1/images/generations \
  -H "Authorization: Bearer $APIMODELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-image-2.6-flare",
    "prompt": "A round blue robot mascot waves hello. Seamless loop, same framing in every frame.",
    "output_format": "gif",
    "frame_count": 6,
    "frame_duration_ms": 150,
    "quality": "low",
    "aspect_ratio": "1:1",
    "resolution": "1K"
  }'
# returns { "data": { "taskId": "..." } }

curl -s "https://api.apimodels.app/v1/images/generations?task_id=TASK_ID" \
  -H "Authorization: Bearer $APIMODELS_API_KEY"
# once data.state is "completed": data.resultUrls[0] is the GIF, data.credits is the charge
Enter fullscreen mode Exit fullscreen mode

The same request through the helper:

import { run } from './gpt26.mjs'

const mascot = await run({
  model: 'gpt-image-2.6-flare',
  prompt: 'A round blue robot mascot waves hello. Seamless loop, same framing in every frame.',
  output_format: 'gif',
  frame_count: 6,
  frame_duration_ms: 150,
  quality: 'low',
  aspect_ratio: '1:1',
  resolution: '1K', // apimodels.app shrinks each frame to 960x960 so the sheet fits (Step 2)
  // or pin an exact frame size, e.g. the 832x832 our test used:
  // image_size: { width: 832, height: 832 },
})
console.log(mascot.urls[0])
Enter fullscreen mode Exit fullscreen mode

Our run used 832×832 frames, took 25 s and comes to $0.0309 at today's rates. It came back as a real six-frame animated GIF that loops at 150 ms per frame. The character, framing and background held still; only the arm moved.

Six-frame animated GIF from GPT Image 2.6 Flare: a round blue robot mascot waving hello on a loop

Animate an image you already have

Adding a reference to image_urls makes the same request animate your picture instead of inventing one. We passed a line drawing of a bird from an earlier Flare request and asked for a blink, a hop and a pink heart floating up, keeping the line style and adding only a pink blush.

Black-and-white line art of a round bird on a leafy branch, used as the GIF reference

const bird = await run({
  model: 'gpt-image-2.6-flare',
  prompt:
    'Animate this drawing as a six-frame loop: the bird blinks, hops, and a small pink heart floats up. ' +
    'Keep the exact line style. The only colour you may add is a pink blush on the cheeks.',
  image_urls: ['https://example.com/bird-line-art.png'], // placeholder
  output_format: 'gif',
  frame_count: 6,
  frame_duration_ms: 150,
  quality: 'low',
  image_size: { width: 960, height: 720 }, // the frame size our test came back at
})
Enter fullscreen mode Exit fullscreen mode

This one took 22 s and comes to $0.0501. Every frame kept the line weight and style, the blush was the only colour added, and the three actions played in order. It costs $0.0192 more than the mascot although the frames hold about the same number of pixels. The reference image and the longer prompt add input tokens, but GIFs carry no token breakdown, and GIF cost also moves with the image itself: the text-only 976×672 loop in Step 2 comes to $0.0441.

Six-frame animated GIF: the line-art bird blinks, hops and a pink heart floats up, with a pink blush as the only added colour

Step 2: Size the frames so the sheet fits

GPT Image 2.6 draws all GIF frames on one sheet, and the sheet has two limits: its longest edge must be 3840 px or less, and it must hold no more than 8,294,400 pixels in total, the same budget as a single image. The frame count rounds up to a power of two and fills a grid: 6 frames take an 8-cell grid laid out 4×2 (wide) or 2×4 (tall), and 12 frames take a 16-cell 4×4 grid. A request fits if the whole grid stays within the pixel budget and either layout stays within 3840 px.

The edge limit explains our first failure. Six frames at 1024×1024 make a 4096×2048 sheet laid out wide and a 2048×4096 sheet laid out tall; both are over the limit, and the upstream error listed exactly those two sheet sizes. The request was rejected and not charged.

Checking both layouts matters for wide frames. To confirm it, we ran one more six-frame request at 976×672, a size that only fits tall: four frames across would be 3904 px, but two across and four down make a 1952×2688 sheet. It worked in 20 s and came back as a six-frame 976×672 GIF.

Frames Frame size Sheet Result
6 1024×1024 4096×2048 wide or 2048×4096 tall: long edge too big Rejected, not charged
6 832×832 3328×1664 or 1664×3328 Worked: 25 s, $0.0309
6 960×720 3840×1440 wide Worked: 22 s, $0.0501 (with a reference image)
6 976×672 wide 3904×1344 too big; tall 1952×2688 fits Worked: 20 s, $0.0441
9 960×960 laid out 3×3: 2880×2880, exactly 8,294,400 px Worked: $0.0833
12 960×960 4×4: 3840×3840, 14.7 million px, over the budget Rejected, not charged
12 720×720 4×4: 2880×2880, exactly 8,294,400 px Worked: $0.0501

A correction: we first read the rule as "960×960 frames fit up to 16 per GIF", with only the edge limit in play, and that was wrong. Later on 5 November a real 12-frame request at 960×960 was rejected for exceeding the pixel budget, because a 4×4 grid of 960×960 frames is a 3840×3840 sheet. Our nine-frame 960×960 loop had worked only because it was laid out 3×3, which sits exactly at the cap. Don't count on that tighter layout.

These are the per-frame limits that follow from both rules:

frame_count Grid Per-frame limit Fits, for example
2 2×1 or 1×2 doubled side ≤ 1920, other side ≤ 3840, ≤ 4,147,200 px 1920×2160, 1920×1088
3–4 2×2 both sides ≤ 1920, ≤ 2,073,600 px 1440×1440, 1920×1072
5–8 4×2 or 2×4 short side ≤ 960, long side ≤ 1920, ≤ 1,036,800 px 960×960, 1344×768
9–16 4×4 both sides ≤ 960, ≤ 518,400 px 720×720, 944×528
17–32 8×4 or 4×8 short side ≤ 480, long side ≤ 960, ≤ 259,200 px 480×480, 672×384
33–64 8×8 both sides ≤ 480, ≤ 129,600 px 352×352

The common 16:9 size 1360×768 is just over for 5–8 frames (1,044,480 px), so drop to 1344×768. We tested 6, 9 and 12 frames; the other rows follow from the same rule, and we did not test more than 16 frames.

The simplest fix is to let the server pick the frame size. When you pass aspect_ratio + resolution, apimodels.app shrinks each frame to the largest size that fits, so 6 frames at 1:1 1K come back at 960×960 and 12 frames at 720×720. If you pin an exact image_size, it is sent as-is, a frame that is too large fails without a charge, and the check is yours. This example pre-flight function encodes both limits:

// Example: check that a GIF request fits on one frame sheet: long edge <= 3840 px
// and no more than 8,294,400 px in total. It assumes the power-of-two grid, so it is
// conservative for counts the server may lay out more tightly (our 9-frame run went 3x3).
export function gifSheetFits(frames, width, height, maxEdge = 3840, maxPixels = 8_294_400) {
  const k = Math.ceil(Math.log2(Math.max(2, frames)))          // 6 -> 8 cells, 12 -> 16 cells
  const a = 2 ** Math.ceil(k / 2), b = 2 ** Math.floor(k / 2)  // 8 cells -> 4 and 2, 16 -> 4 and 4
  if (2 ** k * width * height > maxPixels) return false         // the whole grid counts, empty cells too
  // the grid can be laid out a×b or b×a; the sheet fits if either orientation stays within maxEdge
  return [[a, b], [b, a]].some(([cols, rows]) => cols * width <= maxEdge && rows * height <= maxEdge)
}

gifSheetFits(6, 1024, 1024) // false: 4096 px either way
gifSheetFits(6, 832, 832)   // true:  3328 x 1664
gifSheetFits(6, 960, 720)   // true:  3840 x 1440
gifSheetFits(6, 976, 672)   // true:  only tall, 1952 x 2688
gifSheetFits(6, 1360, 768)  // false: 8 x 1360 x 768 = 8,355,840 px, just over
gifSheetFits(12, 960, 960)  // false: 3840 x 3840 is 14.7 million px
gifSheetFits(12, 720, 720)  // true:  2880 x 2880, exactly 8,294,400 px
gifSheetFits(9, 960, 960)   // false: conservative; don't rely on a 3x3 layout
Enter fullscreen mode Exit fullscreen mode

The function only checks the sheet. An exact size also has to pass the general size rules: both sides multiples of 16 and a ratio of 3:1 or less.

Step 3: Draw a markup PNG

A markup image is a transparent PNG, the same size as the image you are editing, that contains only your annotations: circles, arrows and short notes. It is the API version of the ChatGPT image markup that OpenAI announced. The API takes it as markup_image_url; you can send a markup image or a mask (mask_url, white = change, black = keep), not both.

Our source was the Sunburst poster from the cost comparison in Step 6, at 1360×768. The markup circled two jars, added an arrow and the note "swap for a small potted plant". This script draws the same kind of layer with sharp; the coordinates are placeholders, so move them to wherever your object is:

// make-markup.mjs — example: a transparent PNG with only annotations
import sharp from 'sharp'

// The markup layer must be the same size as the image you are editing.
const { width, height } = await sharp('poster.png').metadata()

// Placeholder coordinates: move the circle, arrow and note to your own object.
const red = '#e53935'
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
  <defs>
    <marker id="head" viewBox="0 0 10 10" refX="5" refY="5"
            markerWidth="4" markerHeight="4" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="${red}"/>
    </marker>
  </defs>
  <ellipse cx="1235" cy="440" rx="115" ry="150"
           fill="none" stroke="${red}" stroke-width="8"/>
  <line x1="1110" y1="250" x2="1160" y2="315"
        stroke="${red}" stroke-width="8" marker-end="url(#head)"/>
  <text x="830" y="228" font-family="Helvetica, Arial, sans-serif"
        font-size="38" fill="${red}">swap for a small potted plant</text>
</svg>`

await sharp(Buffer.from(svg)).png().toFile('markup.png')

const meta = await sharp('markup.png').metadata()
console.log(meta.width, meta.height, meta.hasAlpha) // must match the source; alpha must be true
Enter fullscreen mode Exit fullscreen mode

For our 1360×768 poster, the last line prints 1360 768 true. If the size does not match the source, or the background is not transparent, fix that before you send anything. Do not flatten the marks onto the photo; the layer should carry the annotations and nothing else.

Step 4: Send the markup-guided edit

The edit passes the original as the first reference and the layer as markup_image_url. Upload both files somewhere the API can fetch them first:

const round1 = await run({
  model: 'gpt-image-2.6-sunburst',
  prompt:
    'Apply the marked change: replace the circled jars with a small potted plant. ' +
    'Keep everything else exactly as it is.',
  image_urls: ['https://example.com/poster.png'],     // placeholder: the image to edit
  markup_image_url: 'https://example.com/markup.png', // placeholder: same size as poster.png
  quality: 'medium',
})
Enter fullscreen mode Exit fullscreen mode

Sunburst replaced exactly the circled objects with a plant in a speckled pot, and it did not paint the red marks into the result. The headline, the dripper, the window and the light were unchanged. The call took 35 s and comes to $0.0391.

Three panels: the original Slow Mornings poster, the same poster with a red circle, arrow and note over two jars, and the Sunburst result with a small potted plant in their place

An edit without a size keeps the input's shape but not necessarily its pixel size. Ours came back at 1668×943, slightly larger than the 1360×768 input. If later code expects fixed dimensions, set the size yourself, for example aspect_ratio: '16:9', resolution: '1K', which gave 1360×768 for the text-to-image posters. We did not pin the size in this test.

Step 5: Run a second edit round

A second round is another edit whose first reference is the previous result. No markup this time; the prompt names the one thing to change:

const round2 = await run({
  model: 'gpt-image-2.6-sunburst',
  prompt: 'Repaint only the wall a soft sage green. Keep everything else.',
  image_urls: [round1.urls[0]],
  quality: 'medium',
})
Enter fullscreen mode Exit fullscreen mode

Only the wall changed. The typography, the glaze pattern, the plant from round 1 and the shadows matched the previous round. The call took 25 s and comes to $0.0399. Because this round is a plain prompt edit with a PNG result, it also returns usage; the markup edit in round 1 does not.

Two panels: round one of the edit with a cream wall, and round two with the wall repainted sage green while the dripper, plant and headline stay the same

Step 6: Read what each call cost

data.credits is the exact amount each request was charged, which is why the helper returns it. Summing it gives the bill for a whole run:

const calls = [mascot, bird, round1, round2]
console.log(`total: $${calls.reduce((sum, call) => sum + call.credits, 0).toFixed(4)}`)
console.log(round2.usage?.cost) // { currency, amount, discount, charged }; undefined for GIFs and markup edits
Enter fullscreen mode Exit fullscreen mode

GPT Image 2.6 has no fixed per-image price on apimodels.app; it is billed per token, at the same rates as OpenAI's official API:

  • Image output: $30 per 1M tokens
  • Image input: $8 per 1M tokens ($2 cached)
  • Text input: $5 per 1M tokens ($1.25 cached)
  • Text output: $10 per 1M tokens
  • Each request is rounded up to $0.0001
  • SVG is the exception: a flat $0.035 per image

Quality, size, GIF frame count, reference and markup images, and prompt length all change the token count. When you submit, an upper bound is held from your balance, and the unused part is released when the image is ready. Failed requests are not charged, which is why our rejected GIFs and our launch-day SVG attempts cost nothing. Not every request succeeds, either: moderation refusals and upstream errors happen.

Completed PNG, JPEG and WebP results, from text-to-image or a plain edit, also carry usage: input and output tokens split into text and image, in the OpenAI Images shape. Inside it, cost has amount (the official-rate price before any account discount), discount (your account's price multiplier, 1 for none) and charged, which equals credits; rely on charged. You get it from the task query, the task callback, and the synchronous OpenAI-compatible responses of /v1/images/generations and /v1/images/edits. GIFs, markup or mask edits, SVG and failed tasks don't include it.

Here are all seven launch-day tests, run on 5 November 2026. Time is what each request took that day; cost is that request's token usage at today's prices, since prices moved to OpenAI's official rates after these tests:

# Test Model Settings Time Cost
1 Product poster: ceramic pour-over dripper, headline "SLOW MORNINGS", line "Hand-glazed in Kyoto" Flare 1K, 16:9, medium (1360×768) 16 s $0.0257
2 Same prompt Sunburst Same 28 s $0.0257
3 Line art of a bird on a branch Flare 1K, low, 4:3 (1152×864) 11 s $0.0184
4 Six-frame GIF loop: robot mascot waves hello Flare 6 frames, 832×832, low, 150 ms/frame 25 s $0.0309
5 Test 3 as reference: six-frame loop, bird blinks, hops, heart floats up Flare 6 frames, 960×720, low 22 s $0.0501
6 Markup edit on the Sunburst poster: swap two jars for a potted plant Sunburst Medium, size follows input (1668×943) 35 s $0.0391
7 Second round on test 6: repaint only the wall sage green Sunburst Medium 25 s $0.0399

The seven come to $0.2298. Plain 1K images now usually report fewer tokens than these launch-day calls, so a similar 1K image today typically costs less: about $0.008–0.016 at low quality, about $0.016 at high, and about $0.016 for a 1K edit with one reference. At 2K, low quality came to $0.0283 and medium to $0.0516. Timings moved too: in a re-test that evening, a plain 1K text-to-image took about 20 s (about 27 s at high quality), a 1K edit with one reference 22–58 s, and 2K and 4K images, high quality included, 13–25 s.

The two tiers cost the same for the same poster. Identical settings used the same tokens, so Sunburst's extra ~12 s bought detail, not a bigger bill. Both tiers spelled the poster text correctly; Flare staged the dripper on a glass server with coffee, and Sunburst gave the glaze more texture and a sparser scene.

Two posters side by side: Flare on the left with the dripper on a glass coffee server, Sunburst on the right with a more textured glaze and a sparser scene

SVG failed on launch day, and works now

OpenAI lists SVG export for Images 2.6, but on launch day every SVG request we sent failed upstream. That held on both tiers and even for a three-word prompt, "a red apple". None of those requests were billed.

SVG has since been enabled on apimodels.app. output_format: "svg" works for text-to-image only, one image per request, at a flat $0.035 per image, and the quality setting has no effect on it. Most SVGs come back in 13–18 s, though we saw one take about two minutes. The result is a static vector with no scripts or embedded raster, which suits icons, logos and flat illustrations rather than detailed artwork.

Where apimodels.app fits

apimodels.app handled three things in this walkthrough that you would otherwise handle yourself. It shrinks GIF frames to fit the sheet when you pass aspect_ratio + resolution. It rejects requests that cannot work with a 400 up front, such as an SVG request with a reference image or a reasoning_effort other than low, medium or high. And it reports what each request cost in data.credits, with the token breakdown in usage for PNG, JPEG and WebP results. The official OpenAI SDK also works against it with base_url set to https://api.apimodels.app/v1, where an exact WxH size makes images.generate synchronous and the response carries usage at the top level. Every parameter, size table and limit is in the GPT Image 2.6 API docs on apimodels.app.

When not to use GPT Image 2.6

  • You need a fixed, predictable price per image for budgeting. GPT Image 2.6 is billed per token, so the cost moves with size, quality, frames and inputs. GPT Image 2.5 on apimodels.app is sold per image by resolution × quality.
  • You need detailed vector artwork. Its SVG output is a simple static vector for icons and flat illustrations (see above).
  • You want video. The GIFs are short loops of small movements, like a wave, a blink or a hop, not clips with camera moves or a story.

Common errors

  • Six 1024×1024 frames get rejected. The sheet would be 4096 px whichever way it is laid out. For 5–8 frames keep the short side at 960 px or less, the long side at 1920 px or less and each frame at 1,036,800 px or less (run gifSheetFits from Step 2), or pass aspect_ratio + resolution and let apimodels.app size them.
  • Twelve 960×960 frames get rejected. The long edge is exactly 3840 px, but the 4×4 sheet holds 14.7 million pixels, over the 8,294,400 budget. For 9–16 frames keep each frame at 518,400 px or less, for example 720×720.
  • output_format: "svg" returns 400. SVG works for text-to-image only, one image per request. Drop the reference images and ask for a single image.
  • reasoning_effort: "xhigh" or "max" returns 400. Only low, medium and high are accepted.
  • The response has an image instead of a taskId. An exact WxH size makes the call synchronous and returns the OpenAI Images response shape. For the polling flow, use aspect_ratio + resolution or image_size.
  • Markup and mask in the same request. Send markup_image_url or mask_url, not both.
  • The markup layer does not match the source. It must be the same size as the first reference image. Build it from the source's own metadata, as in Step 3.
  • The edited image has different dimensions. An edit with no size follows the input image, and ours grew from 1360×768 to 1668×943. Set the size if your pipeline needs exact pixels.

Limits

  • The numbers come from one day of testing (5 November 2026), one result per setting. Your timings will vary.
  • Costs are each launch-day request's token usage at today's prices, which match OpenAI's official per-token rates.
  • The GIF sheet rule was confirmed for 6 frames (both layouts), 9 frames and 12 frames. The behaviour for other counts follows from the same rule but was not tested.
  • We did not test mask_url or num_images.

Has anyone pushed frame_count past 16 yet? I would like to know whether the sheet rule holds the way the math says it should.


I work on apimodels.app. The full launch-day write-up, with all seven test images, is the GPT Image 2.6 API test write-up on the apimodels.app blog.

Top comments (0)