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.
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 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.
<!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>