écrits/tutorial/2026/07
Tutorial31 juil. 2026·32 min

Créer une application de génération d'images avec Gemini Nano Banana Pro et Next.js

Guide de production du modèle d'images Nano Banana Pro de Google dans Next.js : services typés de génération et d'édition, composition par images de référence, texte arabe et multilingue lisible dans les images, stockage WebP, limitation de débit, hiérarchisation des coûts et provenance SynthID.

La démo qui meurt en production

Une démo de génération d'images est la chose la plus facile au monde à construire. Dix lignes, une clé d'API, un champ de prompt, et une image apparaît. La démo fonctionne. Tout le monde applaudit.

Puis elle passe en production et les vraies questions arrivent d'un coup. Que se passe-t-il quand le modèle renvoie du texte au lieu d'une image parce que le filtre de sécurité s'est déclenché ? Où stockez-vous un PNG de 4 Mo qu'un utilisateur regardera une seule fois ? Qui paie quand quelqu'un découvre votre endpoint non authentifié et génère quatorze mille images pendant la nuit ? Pourquoi le texte arabe de l'affiche ressort-il en lettres désolidarisées ? Et quand un client demande si une image a été générée par IA, que lui répondez-vous exactement ?

Ce tutoriel construit la version qui survit à ces questions. Nous utiliserons Nano Banana Pro de Google — le modèle de génération et d'édition d'images exposé via l'API Gemini sous le nom gemini-3-pro-image-preview — dans un projet Next.js App Router, en traitant chacun de ces points comme une exigence de conception et non comme une réflexion après coup.

Ce que vous allez construire

Un studio d'images d'une seule page, avec un vrai backend derrière :

  • Un service de génération typé qui gère le ratio d'aspect, la résolution et les modes de défaillance que le SDK ne modélise pas pour vous
  • Une route d'API qui valide les entrées, applique une limitation de débit par utilisateur et ne fait jamais confiance au client sur le choix du modèle facturé
  • L'édition conversationnelle : téléversez une image de référence, demandez une modification, obtenez une nouvelle image qui conserve la cohérence du sujet
  • Le rendu de texte multilingue, y compris le cas arabe que la plupart des modèles d'images ratent
  • Un stockage qui convertit en WebP avant d'atteindre votre bucket, car la sortie brute du modèle n'est pas ce que vous voulez servir
  • Une hiérarchisation des coûts entre le modèle rapide et le modèle pro, plus un cache qui vous évite de payer deux fois le même prompt

À la fin, vous aurez environ 400 lignes de code applicatif et une vision claire de l'endroit où part l'argent.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ et un gestionnaire de paquets (ce guide utilise pnpm)
  • Une clé d'API Gemini depuis Google AI Studio, dans une variable d'environnement
  • De l'aisance avec l'App Router de Next.js — route handlers, server actions et la séparation composants serveur / client
  • Des bases en TypeScript — nous nous appuyons sur les types pour capturer les formes de réponse qui varient à l'exécution
  • Optionnel mais recommandé : un bucket compatible S3 et une instance Upstash Redis pour les étapes de stockage et de limitation de débit

Si l'API Gemini elle-même est nouvelle pour vous, notre guide de l'API Gemini avec TypeScript couvre le versant texte du même SDK et constitue un bon complément.

Étape 1 : mise en place du projet

Créez le projet et installez le SDK :

pnpm create next-app@latest image-studio --typescript --app --tailwind
cd image-studio
pnpm add @google/genai zod sharp
pnpm add -D @types/node

Attention au nom du paquet. @google/genai est le SDK unifié actuel de Google Gen AI, couvrant à la fois l'API Gemini Developer et Vertex AI. L'ancien paquet @google/generative-ai est une bibliothèque différente et dépréciée — suivre un tutoriel écrit pour celle-ci vous mènera dans une impasse.

Placez votre clé dans .env.local :

GEMINI_API_KEY=your_key_here

Ne l'exposez jamais au navigateur. Dans une implémentation correcte, il n'existe aucune version NEXT_PUBLIC_ de cette variable — chaque appel au modèle se fait côté serveur, derrière un endpoint que vous contrôlez. Une clé de modèle d'images qui fuite est une carte bancaire à compteur.

Étape 2 : le plus petit appel qui fonctionne

Avant de construire quoi que ce soit autour, vérifiez la surface d'API avec un script jetable. Créez scripts/smoke.ts :

import { GoogleGenAI } from '@google/genai'
import { writeFileSync } from 'node:fs'
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
 
const response = await ai.models.generateContent({
  model: 'gemini-3-pro-image-preview',
  contents: 'A cross-section diagram of a Tunisian olive press, technical illustration style, labelled parts, warm ochre palette',
  config: {
    responseModalities: ['IMAGE'],
    imageConfig: {
      aspectRatio: '16:9',
      imageSize: '2K',
    },
  },
})
 
for (const part of response.candidates?.[0]?.content?.parts ?? []) {
  if (part.inlineData?.data) {
    writeFileSync('out.png', Buffer.from(part.inlineData.data, 'base64'))
    console.log('saved out.png')
  }
}

Exécutez-le :

GEMINI_API_KEY=$GEMINI_API_KEY pnpm dlx tsx scripts/smoke.ts

Trois détails de cet extrait comptent plus qu'il n'y paraît.

responseModalities: ['IMAGE'] est ce qui transforme un appel de modèle texte en appel d'image. Omettez-le et vous obtiendrez un paragraphe poli décrivant un pressoir à olives.

imageConfig porte aspectRatio (les valeurs prises en charge incluent 1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9 et 21:9) et imageSize (1K, 2K, 4K, avec 1K par défaut). La résolution est une décision de facturation, pas un curseur de qualité à pousser au maximum par réflexe : le 4K coûte sensiblement plus cher par image et la plupart des surfaces web ne l'affichent jamais.

La réponse est un tableau de parts, pas une image. Une seule réponse peut contenir des parts texte, des parts image, ou les deux, et le modèle renvoie parfois uniquement du texte. Tout code qui accède directement à parts[0].inlineData.data plantera en production dès qu'un prompt déclenchera un filtre.

Étape 3 : un service de génération qui modélise l'échec

Encapsulez le SDK dans quelque chose auquel votre route handler peut se fier. Créez lib/gemini-image.ts :

import { GoogleGenAI } from '@google/genai'
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! })
 
export const MODELS = {
  fast: 'gemini-2.5-flash-image',
  pro: 'gemini-3-pro-image-preview',
} as const
 
export type ModelTier = keyof typeof MODELS
 
export type ReferenceImage = {
  data: string      // base64, sans préfixe data-URL
  mimeType: string  // 'image/png' | 'image/jpeg' | 'image/webp'
}
 
export type GenerateOptions = {
  prompt: string
  tier?: ModelTier
  aspectRatio?: string
  imageSize?: '1K' | '2K' | '4K'
  references?: ReferenceImage[]
}
 
export type GenerateResult =
  | { ok: true; image: Buffer; mimeType: string; note?: string }
  | { ok: false; reason: 'no_image' | 'blocked' | 'error'; message: string }
 
export async function generateImage(
  options: GenerateOptions,
): Promise<GenerateResult> {
  const {
    prompt,
    tier = 'pro',
    aspectRatio = '1:1',
    imageSize = '2K',
    references = [],
  } = options
 
  const parts: Array<Record<string, unknown>> = [
    ...references.map((ref) => ({
      inlineData: { data: ref.data, mimeType: ref.mimeType },
    })),
    { text: prompt },
  ]
 
  try {
    const response = await ai.models.generateContent({
      model: MODELS[tier],
      contents: [{ role: 'user', parts }],
      config: {
        responseModalities: ['IMAGE'],
        imageConfig: { aspectRatio, imageSize },
      },
    })
 
    const candidate = response.candidates?.[0]
 
    if (candidate?.finishReason === 'SAFETY' || candidate?.finishReason === 'PROHIBITED_CONTENT') {
      return {
        ok: false,
        reason: 'blocked',
        message: 'La requête a été bloquée par le filtre de sécurité.',
      }
    }
 
    let image: Buffer | null = null
    let mimeType = 'image/png'
    let note: string | undefined
 
    for (const part of candidate?.content?.parts ?? []) {
      if (part.inlineData?.data) {
        image = Buffer.from(part.inlineData.data, 'base64')
        mimeType = part.inlineData.mimeType ?? 'image/png'
      } else if (part.text) {
        note = part.text
      }
    }
 
    if (!image) {
      return {
        ok: false,
        reason: 'no_image',
        message: note ?? 'Le modèle n\'a renvoyé aucune image pour ce prompt.',
      }
    }
 
    return { ok: true, image, mimeType, note }
  } catch (error) {
    return {
      ok: false,
      reason: 'error',
      message: error instanceof Error ? error.message : 'Erreur inconnue',
    }
  }
}

Le type de retour est l'essentiel. Une union discriminée oblige l'appelant à traiter « bloqué » et « pas d'image » comme des résultats ordinaires plutôt que comme des exceptions, ce qu'ils sont exactement : un refus de sécurité est une réponse normale d'une API qui fonctionne, pas un bug.

Remarquez aussi que les references viennent en premier dans le tableau de parts et que le prompt texte vient en dernier. Le modèle lit les images comme contexte et le texte final comme instruction ; placer l'instruction en premier produit une adhérence nettement plus faible dans les tâches d'édition.

Étape 4 : la route d'API

Passons à l'endpoint. Créez app/api/generate/route.ts :

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { generateImage } from '@/lib/gemini-image'
import { storeImage } from '@/lib/storage'
import { checkRateLimit } from '@/lib/rate-limit'
import { getSession } from '@/lib/auth'
 
export const runtime = 'nodejs'
export const maxDuration = 60
 
const BodySchema = z.object({
  prompt: z.string().min(3).max(2000),
  aspectRatio: z.enum(['1:1', '3:4', '4:3', '9:16', '16:9', '21:9']).default('1:1'),
  imageSize: z.enum(['1K', '2K']).default('2K'),
  references: z
    .array(
      z.object({
        data: z.string().max(10_000_000),
        mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
      }),
    )
    .max(3)
    .default([]),
})
 
export async function POST(request: Request) {
  const session = await getSession()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const limit = await checkRateLimit(session.userId)
  if (!limit.success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded', resetAt: limit.reset },
      { status: 429 },
    )
  }
 
  const parsed = BodySchema.safeParse(await request.json())
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid request', details: parsed.error.flatten() },
      { status: 400 },
    )
  }
 
  const result = await generateImage({
    ...parsed.data,
    tier: session.plan === 'pro' ? 'pro' : 'fast',
  })
 
  if (!result.ok) {
    const status = result.reason === 'blocked' ? 422 : 502
    return NextResponse.json({ error: result.message, reason: result.reason }, { status })
  }
 
  const url = await storeImage(result.image, session.userId)
  return NextResponse.json({ url, note: result.note })
}

Plusieurs décisions méritent d'être énoncées explicitement, car ce sont celles que l'on saute.

L'authentification précède la validation. Un endpoint d'images non authentifié n'est pas une faille de sécurité au sens abstrait — c'est une facture directe et mesurable. La limitation de débit vient ensuite, avant d'avoir dépensé le moindre centime en tokens.

Le client ne choisit pas le modèle. tier est dérivé du plan de la session, côté serveur. Si le corps de la requête pouvait définir tier: 'pro', chaque utilisateur gratuit enverrait ce champ dans la journée suivant la publication de votre endpoint sur un forum.

imageSize exclut 4K au niveau du schéma. Vous pouvez l'ajouter pour les offres payantes, mais le schéma est le bon endroit pour trancher, plutôt que de faire confiance à un menu déroulant.

maxDuration = 60. Une génération pro en 2K prend régulièrement quinze à quarante secondes. Le timeout serverless par défaut vous coupera en pleine génération, et vous aurez payé l'image que vous n'avez jamais reçue.

Pour un traitement approfondi de la couche de limitation, voyez notre guide de limitation de débit Upstash ; la recette de fenêtre glissante s'insère directement dans checkRateLimit.

Étape 5 : stockez du WebP, pas ce que le modèle a renvoyé

Le modèle renvoie du PNG. Le PNG est le mauvais format à servir : une image générée en 2K pèse entre 3 et 6 Mo, et la même image en WebP à qualité 82 fait généralement moins de 400 Ko sans différence visible à l'écran.

Créez lib/storage.ts :

import sharp from 'sharp'
import { createHash, randomUUID } from 'node:crypto'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
 
const s3 = new S3Client({
  region: process.env.S3_REGION!,
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
})
 
export async function storeImage(image: Buffer, userId: string): Promise<string> {
  const webp = await sharp(image)
    .webp({ quality: 82, effort: 4 })
    .toBuffer()
 
  const key = `generations/${userId}/${randomUUID()}.webp`
 
  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      Body: webp,
      ContentType: 'image/webp',
      CacheControl: 'public, max-age=31536000, immutable',
    }),
  )
 
  return `${process.env.CDN_BASE_URL}/${key}`
}
 
export function promptFingerprint(input: Record<string, unknown>): string {
  return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 32)
}

Deux points à signaler. effort: 4 est un réglage intermédiaire délibéré : effort: 6 gagne peut-être 8 % de plus sur le fichier pour environ le triple de temps CPU, ce qui est un mauvais compromis à l'intérieur d'une requête qui a déjà pris trente secondes. Et l'en-tête de cache immutable est sûr précisément parce que la clé contient un UUID : les octets à cette URL ne changeront jamais.

promptFingerprint sert à l'étape suivante.

Étape 6 : ne payez pas deux fois la même image

La génération d'images est non déterministe, mais les utilisateurs sont extrêmement répétitifs. Ils modifient un mot, n'aiment pas le résultat, et relancent le prompt d'origine. Mettre en cache les requêtes identiques est le levier de coût le plus efficace de toute l'application.

import { Redis } from '@upstash/redis'
import { promptFingerprint } from './storage'
 
const redis = Redis.fromEnv()
 
export async function cachedGeneration(
  input: { prompt: string; aspectRatio: string; imageSize: string; tier: string },
  produce: () => Promise<string>,
): Promise<{ url: string; cached: boolean }> {
  const key = `img:${promptFingerprint(input)}`
 
  const hit = await redis.get<string>(key)
  if (hit) return { url: hit, cached: true }
 
  const url = await produce()
  await redis.set(key, url, { ex: 60 * 60 * 24 * 7 })
  return { url, cached: false }
}

Empreintez l'ensemble des paramètres, pas seulement le texte du prompt : les mêmes mots en 16:9 et en 9:16 donnent deux images différentes, et un cache qui l'ignore renverra aux utilisateurs le mauvais ratio d'aspect — un bug très pénible à diagnostiquer.

Une réserve à exposer dans votre interface : si un utilisateur veut sincèrement une autre interprétation du même prompt, un cache donne l'impression que l'application est cassée. Ajoutez un bouton « régénérer » qui ajoute un nonce à l'empreinte pour manquer le cache délibérément.

Étape 7 : le client

L'interface se résume à un formulaire, un état de chargement et une image. Les server actions avec useActionState de React 19 la gardent courte. Créez app/studio/generate-form.tsx :

'use client'
 
import { useActionState } from 'react'
import { generateAction } from './actions'
 
const RATIOS = ['1:1', '4:3', '16:9', '9:16', '21:9'] as const
 
export function GenerateForm() {
  const [state, formAction, pending] = useActionState(generateAction, null)
 
  return (
    <div className="grid gap-6 lg:grid-cols-2">
      <form action={formAction} className="flex flex-col gap-4">
        <textarea
          name="prompt"
          rows={5}
          required
          minLength={3}
          maxLength={2000}
          placeholder="Décrivez l'image souhaitée..."
          className="w-full resize-y rounded-lg border p-3"
        />
 
        <select name="aspectRatio" defaultValue="1:1" className="rounded-lg border p-2">
          {RATIOS.map((ratio) => (
            <option key={ratio} value={ratio}>
              {ratio}
            </option>
          ))}
        </select>
 
        <button
          type="submit"
          disabled={pending}
          className="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-50"
        >
          {pending ? 'Génération...' : 'Générer'}
        </button>
 
        {state?.error && (
          <p role="alert" className="text-sm text-red-600">
            {state.error}
          </p>
        )}
      </form>
 
      <div className="flex aspect-square items-center justify-center rounded-lg border bg-neutral-50">
        {pending && <span className="animate-pulse text-sm">Rendu en cours...</span>}
        {state?.url && (
          <img src={state.url} alt="Résultat généré" className="h-full w-full object-contain" />
        )}
      </div>
    </div>
  )
}

Le point d'accessibilité non évident : role="alert" sur le paragraphe d'erreur. Les échecs de génération arrivent vingt à quarante secondes après le clic, bien après qu'un utilisateur de lecteur d'écran a déplacé le focus ailleurs. Sans région live, l'échec est silencieux.

Donnez aussi un ratio d'aspect fixe au conteneur d'aperçu. Sans cela, la page se réagence violemment à l'arrivée de l'image, ce qui, sur une connexion lente, se lit comme une mise en page cassée.

Étape 8 : édition conversationnelle avec images de référence

C'est là que Nano Banana Pro justifie son coût face à un simple modèle texte-vers-image. Il accepte plusieurs images de référence dans un même appel et sait les composer, en maintenant la cohérence du sujet d'une édition à l'autre.

Le service prend déjà cela en charge via references. L'important est la discipline de prompt :

const edited = await generateImage({
  prompt: [
    'Using the product photo as the subject, place it on a polished walnut surface.',
    'Keep the product shape, colour, and label text exactly as in the reference.',
    'Change only the background and lighting: soft window light from the left, shallow depth of field.',
  ].join(' '),
  references: [{ data: productBase64, mimeType: 'image/jpeg' }],
  aspectRatio: '4:3',
  imageSize: '2K',
})

La structure en trois phrases — le sujet, ce qui ne doit pas changer, ce qui doit changer — est nettement plus fiable qu'une seule phrase fluide. Les modèles d'images dérivent sur les attributs non contraints ; nommer explicitement les invariants est ce qui les fixe.

Pour l'édition multi-tours, réinjectez la sortie précédente comme référence de la requête suivante :

let current = initialResult.image
 
for (const instruction of ['make the lighting cooler', 'add a subtle reflection']) {
  const next = await generateImage({
    prompt: `Apply this change and keep everything else identical: ${instruction}`,
    references: [{ data: current.toString('base64'), mimeType: 'image/png' }],
  })
  if (!next.ok) break
  current = next.image
}

Sachez que la qualité se dégrade sur les longues chaînes d'édition : chaque passe est une génération neuve conditionnée par la précédente, donc les petits artefacts s'accumulent. En pratique, gardez les chaînes sous cinq étapes environ et offrez aux utilisateurs une porte de sortie « repartir de l'original ».

Étape 9 : texte arabe et multilingue dans les images

La plupart des modèles d'images produisent de l'arabe qui ressemble à de l'arabe vu de loin et s'effondre de près : lettres non liées, diacritiques flottants, mots inversés. Nano Banana Pro est nettement meilleur sur ce point que ses prédécesseurs, ce qui le rend réellement utilisable pour du travail créatif destiné au marché MENA — mais seulement si vous le sollicitez délibérément.

Ce qui fonctionne :

await generateImage({
  prompt: [
    'A minimal social media banner, deep teal background, geometric Islamic pattern in the corners.',
    'Render this exact Arabic headline, large and centred, in a modern Naskh typeface:',
    '"نقطة للحلول الرقمية"',
    'The text must be fully connected and correctly shaped right-to-left.',
    'No other text anywhere in the image.',
  ].join('\n'),
  aspectRatio: '16:9',
  imageSize: '2K',
})

Quatre règles font la différence :

  1. Citez la chaîne exacte. Demander « un titre en arabe sur les solutions numériques » donne un charabia d'apparence plausible. Fournissez au modèle les caractères littéraux.
  2. Nommez le comportement de l'écriture. Demander explicitement une graphie liée et de droite à gauche réduit mesurablement l'échec des lettres désolidarisées.
  3. Nommez une famille typographique. « Naskh moderne » ou « koufique géométrique » ancre les formes de lettres ; sans cela, le modèle moyenne les styles et produit quelque chose qui n'est ni l'un ni l'autre.
  4. Interdisez tout texte supplémentaire. Les modèles d'images adorent inventer des accroches, et c'est dans cet arabe inventé qu'apparaît le charabia.

Faites toujours vérifier le texte rendu par un lecteur humain avant publication. Cela vaut doublement pour l'arabe, où un mot mal formé ou en miroir est invisible pour un relecteur non arabophone. Si vous construisez des pipelines arabophones plus largement, les pièges de normalisation de notre tutoriel de pipeline RAG arabe s'appliquent aussi au texte des prompts.

Étape 10 : provenance, sécurité et ce que vous dites au client

Toute image produite par les modèles d'images de Google porte SynthID, un filigrane invisible incrusté dans les pixels. Il survit à une compression modérée, à un recadrage et à des ajustements de couleur, et il est détectable par l'outillage de vérification de Google. On peut aussi demander directement à Gemini si une image téléversée porte ce filigrane.

Cela compte commercialement, pas seulement sur le plan éthique. Si vous livrez des visuels générés par IA à un client, la position honnête est de dire que les images sont générées par machine et portent un filigrane détectable. Le découvrir plus tard, par un tiers, est une conversation nettement plus désagréable.

Deux mesures concrètes :

  • Conservez la provenance à côté de l'actif. Stockez l'identifiant du modèle, le prompt, l'horodatage et l'utilisateur pour chaque génération. Quand quelqu'un posera une question sur une image dans huit mois, vous voudrez une requête SQL, pas une fouille archéologique.
  • Ne supprimez pas les métadonnées par habitude. Votre conversion WebP dans sharp retire déjà l'EXIF par défaut. C'est bien pour la vie privée, mais cela signifie que votre enregistrement en base est le seul relevé de provenance que vous contrôlez — traitez-le en conséquence.

Côté sécurité : personGeneration dans imageConfig contrôle si des personnes peuvent être générées, et la politique varie selon les régions. Définissez-le explicitement plutôt que de vous fier au défaut, et associez le filtre du modèle à vos propres contrôles au niveau du prompt. L'approche en couches de notre guide des garde-fous d'agents IA se transpose directement : les filtres côté modèle attrapent les cas évidents, les règles côté application attrapent ce qui contrevient simplement à votre politique.

Tester votre implémentation

Vérifiez les pièces indépendamment plutôt que de cliquer dans l'interface en espérant.

La couche service. Testez l'union, pas seulement le chemin heureux :

import { describe, expect, it } from 'vitest'
import { generateImage } from '@/lib/gemini-image'
 
describe('generateImage', () => {
  it('returns an image buffer for a benign prompt', async () => {
    const result = await generateImage({ prompt: 'a red ceramic bowl', imageSize: '1K' })
    expect(result.ok).toBe(true)
    if (result.ok) expect(result.image.length).toBeGreaterThan(1000)
  }, 60_000)
 
  it('reports blocked prompts without throwing', async () => {
    const result = await generateImage({ prompt: PROMPT_KNOWN_TO_BE_BLOCKED })
    expect(result.ok).toBe(false)
  }, 60_000)
})

Marquez-les comme tests d'intégration : ils coûtent de l'argent réel à chaque exécution, donc ils n'ont pas leur place dans le pipeline de merge request. Exécutez-les sur une planification à la place.

La route. Simulez generateImage et testez ce qui relève de la logique pure : une requête non authentifiée reçoit-elle un 401 avant tout appel au modèle, une session en plan gratuit obtient-elle tier: 'fast', un tableau de références surdimensionné est-il rejeté. Ces tests sont rapides, gratuits, et ont leur place en CI. Notre guide Vitest et Testing Library couvre la configuration des mocks.

Le budget. Le contrôle que l'on oublie : générez cinquante images via l'endpoint réel, puis comparez le tableau de bord de votre fournisseur à ce que vous pensiez dépenser. Si les deux chiffres divergent, trouvez pourquoi avant le lancement plutôt qu'après.

Dépannage

« Le modèle a renvoyé du texte au lieu d'une image. » Presque toujours un responseModalities: ['IMAGE'] manquant, ou un prompt interprété comme une question. Les prompts formulés en instructions (« Une affiche montrant... ») surpassent les questions (« Peux-tu créer... »).

Images vides ou tronquées en 4K. Généralement un timeout, pas un échec du modèle. Augmentez maxDuration, et vérifiez si votre hébergeur plafonne le temps de réponse indépendamment de la configuration du framework.

Ratio d'aspect ignoré. Vérifiez que aspectRatio se trouve bien dans imageConfig, et non à la racine de config. Mal placé, il est silencieusement ignoré — dix minutes de diagnostic réellement agaçantes.

Image de référence rejetée. Retirez le préfixe data:image/png;base64, avant l'envoi. inlineData.data attend uniquement la charge base64 brute, et le SDK ne le fera pas pour vous.

Coûts plus élevés que prévu. Vérifiez trois choses dans l'ordre : votre taux de succès de cache, si imageSize a une valeur par défaut plus grande que voulu, et si une logique de réessai régénère silencieusement sur erreur transitoire. Le troisième est le coupable habituel : un wrapper de retry naïf transforme une génération 4K échouée en trois générations facturées.

Texte arabe déformé. Relisez l'étape 9. Dans les cas tenaces, générez l'image sans texte et composez la typographie avec sharp ou un calque SVG — un rendu de texte déterministe vaut mieux qu'un modèle fiable à 90 % quand le client lit les mots.

Prochaines étapes

  • Ajoutez une progression en streaming. Les longues générations donnent l'impression d'un bug sans retour visuel. Un canal SSE qui signale les états en file, en génération et en stockage coûte peu et change beaucoup la latence perçue.
  • Mettez le travail en file. Au-delà de quelques utilisateurs simultanés, sortez la génération du chemin de la requête vers une tâche de fond. Notre tutoriel Trigger.dev v4 couvre le motif d'exécution durable nécessaire.
  • Instrumentez. Prompt, modèle, latence, coût et résultat par génération, dans une même trace. Langfuse traite les appels de modèles d'images comme ceux de texte.
  • Comparez les fournisseurs. Passer par une passerelle permet de changer de modèle sans toucher au code applicatif ; voyez notre guide de passerelle IA.
  • Étendez le studio. La génération par lot depuis un CSV de prompts, des préréglages de charte qui préfixent un style maison, et une galerie avec historique de régénération sont les trois fonctionnalités que les utilisateurs réclament en premier.

Conclusion

L'écart entre une démo de génération d'images et un produit de génération d'images ne tient pas à la qualité du modèle — il tient à tout ce qui entoure l'appel au modèle. Un type de retour discriminé qui traite un refus de sécurité comme une donnée. Une sélection de palier côté serveur pour que le client ne puisse pas s'auto-promouvoir. Une conversion WebP avant stockage. Un cache par empreinte qui vous évite de payer deux fois la même image. Une structure de prompt explicite pour le rendu de texte, et une provenance honnête à la livraison.

Nano Banana Pro est un modèle réellement solide, en particulier pour la composition par images de référence et la typographie multilingue — le seul cas arabe ouvre des travaux qui n'étaient pas praticables il y a dix-huit mois. Mais le modèle est la partie la moins coûteuse à réussir dans ce système. La partie coûteuse, c'est la plomberie, et vous l'avez maintenant.