La responsable marketing d'Ondée envoie un brief de neuf lignes : le produit, la cible, une promesse chiffrée, la date de lancement, et six expressions interdites par la loi AGEC (« écologique », « biodégradable »…). Si chaque visuel part de ce brief brut, chaque appel au modèle réinvente une palette, une police et un ton. Le premier livrable du studio n'est donc pas une image : c'est un kit de marque, un objet structuré que tout le reste du code lit.
Le mécanisme
Le kit est un modèle Pydantic. Le SDK google-genai le transmet comme response_schema : le modèle ne peut répondre qu'avec ces champs, et response.parsed renvoie directement l'objet typé. Deux décisions rendent ce kit exploitable par le code.
- Les polices sont une énumération fermée. Le modèle choisit parmi les fichiers présents dans
fonts/et dont la licence (SIL OFL 1.1) autorise l'incrustation dans une image commerciale. Une police que vous n'avez pas ne peut pas sortir du schéma. - Le code a le dernier mot sur le contraste. Le modèle propose une couleur de texte et une couleur de fond ;
normaliser()mesure leur contraste WCAG et remplace la couleur de texte si elle passe sous 4,5:1.
flowchart LR B["briefs/ondee.json"] --> P["Prompt + schéma BrandKit"] P --> M["gemini-3.5-flash-lite"] M --> K["BrandKit typé"] K --> N["normaliser()"] N -- "rôle manquant, hex invalide,<br/>contraste < 4,5" --> C["corrections listées"] N --> S["kit utilisable par le code"]
La mise en œuvre
Le schéma, extrait de kit.py :
POLICES = {
"DM Serif Display": {"regular": "DMSerifDisplay-Regular.ttf", "bold": "DMSerifDisplay-Regular.ttf"},
"Inter": {"regular": "Inter-Regular.ttf", "bold": "Inter-Bold.ttf"},
}
PoliceTitre = Literal["DM Serif Display", "Inter"]
PoliceTexte = Literal["Inter"]
class Couleur(BaseModel):
nom: str
hex: str = Field(description="couleur au format #RRGGBB")
role: Literal["primaire", "secondaire", "accent", "fond", "texte"]
class BrandKit(BaseModel):
marque: str
palette: list[Couleur] = Field(description="exactement 5 couleurs, une par rôle : ...")
police_titre: PoliceTitre
police_texte: PoliceTexte
ton: list[str]
regles: list[str]
mots_interdits: list[str]
direction_photo: strL'appel passe par une seule méthode de models.py, réutilisée pour tous les objets structurés du projet :
r = _relancer(lambda: self.client.models.generate_content(
model=self.s.text_model, contents=contenu,
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=schema, temperature=0.4),
))
self._noter(nature, self.s.text_model, r.usage_metadata) # coût réel, depuis usage_metadata
if r.parsed is None:
raise ValueError(f"réponse non conforme au schéma {schema.__name__}")
return r.parsedPuis le code reprend la main. Les mots interdits du brief sont fusionnés d'office (le modèle peut en oublier), et le contraste est vérifié :
fond, texte = par_role["fond"].hex, par_role["texte"].hex
if qc.contraste(fond, texte) < 4.5:
meilleur = max(("#111111", "#FFFFFF"), key=lambda h: qc.contraste(fond, h))
corrections.append(f"texte {texte} sur fond {fond} : ... remplacé par {meilleur}")
par_role["texte"].hex = meilleurLes corrections ne sont pas silencieuses : elles sont enregistrées dans le manifeste et affichées dans la galerie, sous « Corrigé par le code ».
Vérifiez le comportement sans clé :
make setup
make test # dont test_normaliser_corrige_un_texte_illisible et test_police_hors_catalogue_refuseeRésultat obtenu
Exécution réelle du 2026-10-15 (make e2e), kit produit pour Ondée en 1,6 s pour 0,0016 $ :
kit prêt : primaire #8C4A2F « Ambre Chaud », secondaire #D9CDBF, accent #B85D3B,
fond #F7F5F0 « Blanc Cassé », texte #2B1D16 « Brun Profond », 0 correction(s)
police_titre : DM Serif Display police_texte : Inter
mots_interdits : biodégradable, green, magique, miracle, neutre en carbone,
respectueux de l'environnement, révolutionnaire, zéro déchet, écologiqueLe contraste texte sur fond vaut 14,9:1. Le modèle a ajouté trois mots interdits de lui-même (« magique », « miracle », « révolutionnaire ») : utile, et inoffensif puisque la liste ne fait que s'allonger.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| La galerie affiche une règle « le bloc marque occupe 15 % de la largeur, aligné à gauche » alors que le logo est en haut à droite | observé au premier essai : le modèle invente des règles de mise en page que le code n'applique pas | le prompt limite les règles à la photo et à la rédaction ; la mise en page appartient au code. Au second essai, les règles sont devenues « un seul flacon par visuel », « aucune personne reconnaissable » |
| Un titre en Helvetica dans un visuel | police choisie en texte libre, remplacée en silence par une police système | énumération fermée dans le schéma ; Pydantic refuse le reste |
| Texte beige sur fond lin, illisible | le modèle juge la couleur, pas le contraste | normaliser() mesure et remplace |
ValueError: réponse non conforme au schéma | réponse tronquée (limite de tokens) ou refus | relance unique, puis échec explicite : pas de kit par défaut inventé |
La règle de décision
Faites produire au modèle ce qui relève du goût (nuances, ton, direction photo) et gardez dans le code ce qui se mesure ou se licencie (contraste, polices, mots interdits). Surveillez le nombre de corrections par kit : plus d'une correction par campagne signale un prompt à revoir.
Pratique
Le kit d'une nouvelle marque arrive avec texte #6E6E6E sur fond #FFFFFF (4,7:1). Le directeur artistique veut un gris plus clair, #8A8A8A (3,4:1), « plus élégant ». Que fait le studio ?
Sous 4,5:1, le texte courant échoue au critère WCAG 1.4.3. Le studio corrige et le dit : la décision reste traçable et discutable. L'exception titres existe dans WCAG (3:1 pour le texte de grande taille), mais l'appliquer en silence cache le compromis. Bloquer toute la campagne pour une couleur serait disproportionné.
Vous voulez proposer au modèle la police « Futura PT » que l'agence utilise. Quelle est la bonne démarche ?
Le texte est rendu par Pillow, pas par le modèle : sans fichier, pas de police. Et une police commerciale achetée pour le web ne couvre pas toujours l'usage dans des images publicitaires. Le texte libre ramène exactement le problème que l'énumération supprime.
Exercice. Ajoutez une règle : la couleur d'accent ne doit pas être trop proche de la primaire (distance CIE76 inférieure à 15), sinon elle est remplacée par la secondaire et la correction est enregistrée. Écrivez le test d'abord.
Voir la solution
# kit.py, dans normaliser(), après la boucle des rôles
from .qc import _lab, _rgb
import numpy as np
p, a = par_role["primaire"].hex, par_role["accent"].hex
if np.linalg.norm(_lab(_rgb(p)) - _lab(_rgb(a))) < 15:
corrections.append(f"accent {a} trop proche de la primaire {p}, remplacé par la secondaire")
par_role["accent"].hex = par_role["secondaire"].hex
# tests/test_kit_et_textes.py
def test_accent_trop_proche_de_la_primaire():
k = kit_exemple()
k.palette[2].hex = "#9B4B14" # quasi identique à #9A4A12
kit, corrections = normaliser(k)
assert kit.couleur("accent") == kit.couleur("secondaire")
assert any("trop proche" in c for c in corrections)- Je sais contraindre une sortie de modèle avec un schéma Pydantic et une énumération fermée
- Je sais séparer ce que le modèle propose de ce que le code impose
- Je sais calculer un contraste WCAG et corriger une palette
-
make testest vert sur ma machine