Quatre appels indépendants au modèle d'image avec le même prompt donnent quatre flacons différents : une forme d'épaule qui change, un verre plus ou moins foncé, une lumière d'après-midi puis de matin. Sur un fil LinkedIn, le lecteur voit quatre produits. La cohérence ne vient pas du prompt : elle vient d'une image de référence générée une fois et donnée en entrée à chaque déclinaison.
Le mécanisme
sequenceDiagram
participant S as Studio
participant P as gemini-3-pro-image
participant F as gemini-3.1-flash-image
S->>P: prompt (kit, direction photo), 4:5
P-->>S: hero.jpg (référence)
loop 4 formats, une conversation par format
S->>F: [hero.jpg] + composition + ratio
F-->>S: fond sans texte
S->>S: calques + contrôle
opt texte parasite détecté
S->>F: « retire le texte » (même conversation)
F-->>S: fond corrigé
end
end
Trois choix :
- L'image de référence est faite par le modèle Pro (0,14 ). La référence porte la qualité des matières ; les déclinaisons la recopient. La documentation indique jusqu'à 10 images d'objet en référence pour
gemini-3.1-flash-image: une seule suffit ici. - Chaque format ouvre une conversation (
client.chats.create). L'édition multi-tours est la méthode recommandée par la documentation pour itérer sur une image : le modèle garde le contexte du tour précédent au lieu de repartir de zéro. - Le prompt décrit où laisser de l'air, pas le texte. « Produit à droite, la moitié gauche calme et unie » prépare la place que le code remplira.
La mise en œuvre
Les formats et leur ratio de génération, dans layout.py :
FORMATS = {
"linkedin": Format(1200, 627, "16:9", "gauche", "LinkedIn, image de post"),
"instagram_portrait": Format(1080, 1350, "4:5", "bas", "Instagram, post portrait"),
"story": Format(1080, 1920, "9:16", "bas", "Instagram et LinkedIn, story", zone_sure=0.14),
# 4:1 renvoie souvent un triptyque répété : on génère en 21:9 et on recadre.
"banniere": Format(1584, 396, "21:9", "gauche", "LinkedIn, bannière de page"),
}La génération d'un format, dans pipeline.py :
fond = self.m.image(
f"Même produit, même lumière et mêmes matières que l'image de référence. "
f"Nouvelle composition pour {fmt.usage}, une seule scène continue (pas de panneaux répétés) : "
f"produit {place} pour accueillir du texte ajouté plus tard. {SANS_TEXTE}",
refs=[hero], aspect=fmt.aspect, session=self._session(nom),
)Et l'édition, qui réutilise la conversation si elle est en mémoire, ou en ouvre une à partir de l'image courante (cas de la galerie après un redémarrage du serveur) :
def editer(self, session, consigne, image=None, aspect="1:1") -> bytes:
self._garde_budget() # MAX_IMAGES : plafond dur par processus
if session not in self._chats:
chat = self.client.chats.create(model=self.s.image_model, config=config)
self._chats[session] = (chat, self.s.image_model)
message = [types.Part.from_bytes(data=image, mime_type=_mime(image)), consigne]
else:
message = consigne # le modèle a déjà l'image du tour précédent
chat, model = self._chats[session]
r = _relancer(lambda: chat.send_message(message))Le coût de chaque appel est calculé à partir de usage_metadata (tokens d'entrée, tokens image en sortie à 60 $ le million pour Flash) et non estimé :
make campagne # ou make e2e ; le journal affiche le coût de chaque appelRésultat obtenu
Exécution réelle du 2026-10-15 :
20:43:58 INFO studio.models image gemini-3-pro-image coût=0.1401 $
20:44:08 INFO studio.models image gemini-3.1-flash-image coût=0.0686 $
20:44:11 INFO studio.pipeline linkedin v1 : contraste 4.91, palette 10.5, texte parasite False
20:44:20 INFO studio.models image gemini-3.1-flash-image coût=0.0684 $
20:44:23 INFO studio.pipeline instagram_portrait v1 : contraste 13.20, palette 10.0, texte parasite False
20:44:31 INFO studio.models image gemini-3.1-flash-image coût=0.0684 $
20:44:35 INFO studio.pipeline story v1 : contraste 11.94, palette 9.4, texte parasite False
20:44:44 INFO studio.models image gemini-3.1-flash-image coût=0.0685 $
20:44:46 INFO studio.pipeline banniere v1 : contraste 6.01, palette 22.3, texte parasite FalseEnviron 9 s par image, même flacon, même pierre, même lumière sur les quatre formats (voir la planche en page d'accueil).
Deux éditions multi-tours réelles depuis la galerie sur la story : « ajoute une étiquette kraft avec le nom de la marque », puis « retire complètement l'étiquette ». Le modèle a ajouté une étiquette vierge : regenerer() termine toujours la consigne humaine par « Aucun texte, aucune lettre… », et cette contrainte l'a emporté sur « le nom de la marque en grandes lettres ». Au tour suivant, il a retiré l'étiquette sans toucher au cadrage. Coût de chaque tour : 0,068 $, 15 s.
Ce qui casse en production
Le triptyque. Une bannière demandée directement en 4:1 (essai réel du 2026-10-15, 2064x512 pixels) revient avec le même flacon répété trois fois, comme trois photos collées. Le modèle compose mal un format aussi allongé.
Parade : générer dans le ratio disponible le plus proche qui compose encore une scène unique (21:9), écrire « une seule scène continue » dans le prompt, et laisser ImageOps.fit recadrer au format exact.
| Autre symptôme | Cause | Parade |
|---|---|---|
RuntimeError: le modèle n'a pas renvoyé d'image (...), avec la raison de fin renvoyée par l'API | filtre de sécurité, souvent sur des personnes ou des marques réelles | le brief interdit les personnes reconnaissables ; l'erreur remonte en galerie avec sa raison |
| Facture qui double un vendredi soir | une boucle de régénération sans fin | MAX_IMAGES (16 par défaut) lève BudgetDepasse ; MAX_TOURS_QC borne les corrections automatiques à 2 |
| Après un redémarrage de la galerie, une régénération « oublie » les tours précédents | les conversations vivent en mémoire du processus | editer() rouvre une conversation à partir de l'image courante ; pour un historique durable, stocker les tours avec le manifeste |
429 RESOURCE_EXHAUSTED | quota par minute des modèles d'image | _relancer attend 2, 4, 8 s puis abandonne |
La règle de décision
Une image de référence par campagne, générée avec le meilleur modèle ; des déclinaisons avec le modèle rapide, en conversation. Ne demandez jamais au modèle un ratio extrême : demandez le ratio le plus proche qui reste une photo crédible, et recadrez. Surveillez le coût moyen par visuel publié (régénérations comprises), pas le coût par appel.
Pratique
La story de la campagne montre un flacon plus clair que sur les trois autres formats. Que modifiez-vous en premier ?
La cohérence vient de la référence ; un prompt plus précis ne remplace pas une référence manquante. Changer de modèle coûte deux fois plus sans traiter la cause. Retoucher la teinte au pixel abîme les reflets du verre.
Un client demande une bannière 1584x396 et une couverture d'événement 1776x444. Combien d'images générez-vous au minimum ?
Les deux formats ont un ratio de 4:1 et la même zone de texte : un seul fond 21:9 suffit, recadré par ImageOps.fit. Générer en 4:1 ramène le triptyque. Générer pour avoir le choix multiplie le coût et la charge de validation.
Exercice. Ajoutez un format « carré » 1080x1080 pour Instagram, texte en bas. Quelles lignes changent ? Quel est son coût marginal par campagne ?
Voir la solution
Une ligne dans FORMATS :
"carre": Format(1080, 1080, "1:1", "bas", "Instagram, post carré"),Puis un champ carre: Accroche dans Accroches (textes.py), sinon getattr(self.textes.accroches, nom) échoue. Rien d'autre : la boucle de lancer(), la galerie et le contrôle qualité parcourent FORMATS. Coût marginal : une image Flash (≈ 0,068 ), plus 0,068 $ par tour d'édition éventuel.
- Je sais utiliser une image de référence pour garder un produit cohérent entre formats
- Je sais ouvrir une conversation d'image et la réutiliser pour des éditions
- Je sais pourquoi on ne génère pas directement en 4:1
- Je sais plafonner le nombre d'images d'un processus