Streaming Partial Images

With stream: true, both APIs send server-sent events, and partial_images (0-3) requests previews before the final image, each for 100 extra output tokens.

stream.mjs: two previews, then the final imageJavaScript
import fs from 'node:fs';
import OpenAI from 'openai';
const openai = new OpenAI();
const t0 = performance.now();
const stream = await openai.images.generate({
  model: 'gpt-image-2.5-flare',
  prompt: 'A lighthouse on a quiet harbor at dusk, a small boat, painterly book-cover art, '
    + 'deep blue sea and a warm saffron sky, no text',
  size: '1024x1024',
  quality: 'medium',
  stream: true,
  partial_images: 2,                                   // 0-3; each costs 100 extra tokens
});
for await (const event of stream) {
  const ms = Math.round(performance.now() - t0);
  const name = event.type.endsWith('partial_image')
    ? `harbor-partial-${event.partial_image_index}.png` : 'harbor-final.png';
  fs.writeFileSync(name, Buffer.from(event.b64_json, 'base64'));
  console.log(`${String(ms).padStart(6)} ms  ${event.type.padEnd(37)} -> ${name}`);
  if (event.usage) console.log('usage:', JSON.stringify(event.usage.output_tokens_details));
}
Output
 10172 ms  image_generation.partial_image        -> harbor-partial-0.png
 11838 ms  image_generation.partial_image        -> harbor-partial-1.png
 12970 ms  image_generation.completed            -> harbor-final.png
usage: {"image_tokens":592,"text_tokens":0}
The partial images at 10.2 s and 11.8 s and the final image at 13.0 s. Flare, medium quality, 1024x1024, $0.0179
The partial images at 10.2 s and 11.8 s and the final image at 13.0 s. Flare, medium quality, 1024x1024, $0.0179

The previews came late in a 13-second call, and the first was a different composition rather than a blurred draft: treat partials as progress feedback. The final event's 592 tokens were 47 fewer than the formula's 639.

A streamed generation replayed: partial images at 10.2 s and 11.8 s, the final image at 13.0 sHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
</style>
<p><button id="play">Replay the stream (4× speed)</button> <span id="log"></span></p>
<canvas id="c" width="600" height="270"></canvas>
<script>
  const ctx = document.getElementById('c').getContext('2d');
  // Three renderings of "a lighthouse on a quiet harbor at dusk": the partials are different
  // compositions, not blurred drafts of the final image
  function harbor(g, x, y, s, variant) {
    const sky = g.createLinearGradient(0, y, 0, y + s * 0.6);
    sky.addColorStop(0, '#1f3f66');  sky.addColorStop(1, variant === 0 ? '#c98a2a' : '#e09a10');
    g.fillStyle = sky;  g.fillRect(x, y, s, s * 0.6);
    g.fillStyle = '#16435f';  g.fillRect(x, y + s * 0.6, s, s * 0.4);
    const lx = [0.25, 0.7, 0.72][variant];
    g.fillStyle = '#fff3e0';  g.fillRect(x + s * lx, y + s * 0.25, s * 0.07, s * 0.35);
    g.fillStyle = '#b5452f';  g.fillRect(x + s * lx, y + s * 0.33, s * 0.07, s * 0.04);
    if (variant > 0) {
      g.fillStyle = '#ffe9a8';  g.beginPath();  g.arc(x + s * 0.3, y + s * 0.5, s * 0.08, 0, 7);  g.fill();
      g.fillStyle = '#263238';  g.beginPath();                               // the small boat
      g.moveTo(x + s * 0.35, y + s * 0.75);  g.lineTo(x + s * 0.5, y + s * 0.75);  g.lineTo(x + s * 0.46, y + s * 0.8);  g.lineTo(x + s * 0.38, y + s * 0.8);  g.fill();
    }
    if (variant === 2) {                                                     // final: brush texture
      for (let i = 0; i < 40; i++) {
        g.fillStyle = `rgba(255,243,224,${0.08 + (i % 5) * 0.03})`;
        g.fillRect(x + ((i * 37) % s), y + s * 0.62 + ((i * 13) % (s * 0.35)), s * 0.12, 2);
      }
    }
  }
  const events = [
    { at: 10.2, type: 'image_generation.partial_image', index: 0 },
    { at: 11.8, type: 'image_generation.partial_image', index: 1 },
    { at: 13.0, type: 'image_generation.completed', index: 2 },
  ];
  let start, raf;
  const X = s => 20 + s / 14 * 560;
  function draw(now) {
    const elapsed = Math.min(14, (now - start) / 250);                       // 4× speed
    ctx.clearRect(0, 0, 600, 270);
    // Timeline
    ctx.strokeStyle = '#b0bec5';  ctx.lineWidth = 6;  ctx.beginPath();  ctx.moveTo(X(0), 30);  ctx.lineTo(X(14), 30);  ctx.stroke();
    ctx.strokeStyle = '#1f5f8b';  ctx.beginPath();  ctx.moveTo(X(0), 30);  ctx.lineTo(X(elapsed), 30);  ctx.stroke();
    ctx.fillStyle = '#263238';  ctx.font = '11px system-ui';
    for (let s = 0; s <= 14; s += 2) ctx.fillText(`${s} s`, X(s) - 6, 50);
    const shown = events.filter(e => e.at <= elapsed);
    events.forEach((e, i) => {
      const x = 20 + i * 195, arrived = e.at <= elapsed;
      ctx.fillStyle = arrived ? '#e09a10' : '#ccc';  ctx.beginPath();  ctx.arc(X(e.at), 30, 6, 0, 7);  ctx.fill();
      if (arrived) harbor(ctx, x, 70, 170, e.index);
      else { ctx.fillStyle = '#eceff1';  ctx.fillRect(x, 70, 170, 170); }
      ctx.fillStyle = '#263238';
      ctx.fillText(`${e.index < 2 ? 'partial ' + e.index : 'final'} · ${e.at.toFixed(1)} s${e.index < 2 ? ' · +100 tokens' : ''}`, x, 258);
    });
    document.getElementById('log').textContent = shown.length ? `last event: ${shown.at(-1).type}` : 'waiting for the first event…';
    if (elapsed < 14) raf = requestAnimationFrame(draw);
  }
  function play() { cancelAnimationFrame(raf);  start = performance.now() - 36 * 250;  raf = requestAnimationFrame(draw); }
  document.getElementById('play').onclick = play;
  play();                                                                    // starts at 9 s: nothing happens before
</script>