Le brief YAML et le générateur
Dans la plupart des équipes, les prompts d'une campagne vivent dans un fil de discussion, une feuille de calcul et l'historique de navigateur de deux personnes. Trois semaines plus tard, quand la direction demande la même image au format carré, personne ne sait quel prompt, quel modèle ni quelle image de référence l'avait produite. Le brief devient ici un fichier versionné, validé avant le moindre appel, et chaque image produite garde la trace de ce qui l'a créée.
Le mécanisme
sequenceDiagram
participant U as Vous
participant P as pipeline.py
participant B as brief.py
participant G as generateur.py
participant M as Gemini
U->>P: python -m campagne brief.yaml
P->>B: charger() + avertissements()
B-->>P: Brief validé ou BriefInvalide
P->>P: estimer() : total > budget ? arrêt
loop chaque visuel
P->>G: session(modèle, ratio, taille)
G->>M: prompt + images de référence
M-->>G: image + usage_metadata
G-->>P: Rendu (image, durée, coût)
end
P->>U: sorties/ + manifest.json + planche.html
Trois choix structurent le code :
- le brief est validé par un schéma (pydantic) : un canal mal orthographié ou une référence sans droits arrête tout avant la première dépense ;
- une session de conversation par visuel (
client.chats.create) : la génération initiale et ses retouches partagent l'historique ; la documentation présente la conversation multi-tours comme la façon recommandée d'itérer sur une image ; - le budget est vérifié avant le premier appel, sur une estimation fondée sur le prix fixe par image.
La mise en œuvre
Le schéma du brief
class Reference(BaseModel):
id: str
fichier: str
role: Literal["produit", "personnage", "charte", "style"]
droits: str = Field(min_length=3, description="Source et licence : qui a fourni l'image, à quel titre")
class Visuel(BaseModel):
id: str = Field(pattern=r"^[a-z0-9-]+$")
usage: Literal["produit", "rh", "editorial", "evenement", "autre"] = "autre"
canaux: list[str] = Field(min_length=1)
sujet: str
composition: str = ""
cadrage: str = "" # plan, angle, focale
lumiere: str = ""
matiere: str = ""
style: str = ""
contraintes: list[str] = []
negatifs: list[str] = []
references: list[str] = [] # ids de Reference
retouches: list[str] = [] # instructions d'édition multi-tours, dans l'ordre
texte: str | None = None # superposé en post-production
final: bool = False # True : rendu par le modèle finalLe champ droits est obligatoire et non vide : une image de référence dont personne ne sait d'où elle vient ne doit pas entrer dans le pipeline. À côté des erreurs bloquantes, avertissements() applique des règles de studio qui ne bloquent pas : prompt sous-spécifié (moins de deux rubriques parmi composition, cadrage, lumière), visuel produit sans image de référence, logo demandé au modèle, titre trop long.
La session et les reprises
def envoyer(self, parts: list) -> Rendu:
for essai in range(1, self.tentatives + 1):
debut = time.perf_counter()
try:
rep = self.chat.send_message(parts)
break
except errors.APIError as e:
if e.code not in CODES_TRANSITOIRES or essai == self.tentatives:
raise
pause = self.attente * 2 ** (essai - 1)
log.warning("%s : erreur %s, nouvel essai dans %.0f s", self.modele, e.code, pause)
self.dormir(pause)
...
images = [p.inline_data for p in parts_rep if p.inline_data and not getattr(p, "thought", False)]
if not images:
raison = getattr(cand, "finish_reason", None) or getattr(rep.prompt_feedback, "block_reason", None)
raise AucuneImage(f"{self.modele} : pas d'image (raison : {raison}) {texte[:200]}")Seules les erreurs transitoires (429, 500, 503, 504) sont réessayées. Une 400 (argument invalide) ne s'arrangera pas en réessayant. Une réponse sans image n'est pas une exception de l'API : le modèle répond normalement avec du texte et une raison d'arrêt (IMAGE_SAFETY, par exemple). Le pipeline marque alors le visuel en échec, continue les suivants, et la planche l'affiche en rouge.
La configuration de génération porte le ratio et la taille :
config = types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(aspect_ratio=ratio, image_size=taille),
)
return Session(self.client.chats.create(model=modele, config=config), modele, **self.options)Le garde-fou de budget
plans = planifier(brief, modele, tout_final)
total_estime = sum(p.estimation_usd for p in plans)
plafond = budget if budget is not None else brief.budget_usd
if total_estime > plafond:
raise BudgetDepasse(f"estimation {total_estime:.2f} USD > budget {plafond:.2f} USD : rien n'a été généré")Les tests le vérifient avec un faux client qui enregistre chaque appel : avec un budget de 0,10 $, le journal des appels reste vide. Tous les tests utilisent ce faux client (conftest.py), ce qui permet de tester la campagne complète (5 visuels, 6 appels, recadrages, planche) en une seconde, sans clé.
Résultat obtenu
Les erreurs du brief sont attrapées avant tout appel :
$ .venv/bin/python -m campagne tests/_t.yaml --dry-run # taille: 512 sans guillemets
ERREUR tests/_t.yaml : taille : Input should be '512', '1K', '2K' or '4K'
$ .venv/bin/python -m campagne tests/_t.yaml --dry-run # canal « linkedin »
ERREUR tests/_t.yaml : visuels.0.canaux : Value error, canal inconnu ['linkedin'], attendus : ['instagram_feed', 'instagram_story', 'linkedin_carre', 'linkedin_post', 'print_a5', 'site_hero', 'site_vignette']
$ .venv/bin/python -m campagne brief.yaml --budget 0.3
ERREUR estimation 0.56 USD > budget 0.30 USD : rien n'a été généréPuis la campagne réelle :
20:43:03 INFO gemini-3-pro-image : image en 22.9 s, 0.1411 USD
20:43:11 INFO gemini-3.1-flash-image : image en 8.0 s, 0.0685 USD
20:43:20 INFO gemini-3.1-flash-image : image en 8.3 s, 0.0683 USD
20:43:30 INFO gemini-3.1-flash-image : image en 9.7 s, 0.0689 USD
20:43:38 INFO gemini-3.1-flash-image : image en 8.5 s, 0.0682 USD
20:44:04 INFO gemini-3-pro-image : image en 25.6 s, 0.1416 USD
5/5 visuels, coût réel 0.557 $ -> sorties/campagne/planche.htmlSix appels pour cinq visuels : le deuxième visuel a une retouche, envoyée dans la même session. Durée totale : environ 1 minute 25.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
taille : Input should be '512'… | taille: 512 sans guillemets : YAML lit un entier | guillemets dans le brief ; le message d'erreur nomme le champ |
| Toute la campagne s'arrête au troisième visuel | une réponse sans image levée comme exception fatale | AucuneImage attrapée par visuel, statut echec, la suite continue |
| La retouche ignore l'image précédente | retouche envoyée dans un nouvel appel sans historique | une session de chat par visuel ; la retouche part seule dans la session |
| Un lot de 200 visuels échoue à moitié sur des 429 | pas de reprise, ou reprise immédiate qui aggrave | attente exponentielle (5, 10, 20 s) limitée à 4 essais ; au-delà, API Batch |
client has been closed en plein lot | client genai.Client() créé dans une fonction puis libéré | un seul client, créé dans main() et passé au pipeline |
| Le même brief, relancé, écrase la campagne validée | sortie unique sorties/campagne | --sortie sorties/<date> pour chaque lancement à conserver |
La règle de décision
Mettez le brief sous git dès la deuxième campagne. Rendez obligatoire tout champ dont l'absence vous a déjà coûté une régénération (les droits des références d'abord). Gardez en avertissement, et non en erreur, les règles de goût : un directeur artistique doit pouvoir passer outre en connaissance de cause.
Pratique
Sur un lot de 60 visuels, 3 reviennent sans image avec finish_reason IMAGE_SAFETY. Quel comportement attendez-vous du pipeline ?
Un refus de sécurité est déterministe à prompt égal : réessayer coûte sans résoudre, et contourner le filtre en changeant de modèle à l'insu de l'équipe est une mauvaise pratique. Arrêter tout gaspille 57 images valides. La reformulation (souvent : retirer une ambiguïté sur une personne ou une marque) est une décision humaine.
Un collègue propose de mettre budget_usd: 1000 « pour ne jamais être bloqué ». Le brief contient 12 visuels. Que lui répondez-vous ?
Le garde-fou n'a de valeur que s'il est proche de la dépense prévue : il bloque une erreur (taille 4K par défaut, visuels dupliqués, retouches en boucle) avant qu'elle coûte. À 1 000 $, il ne protège de rien. Le coût réel affiché après coup arrive trop tard.
Exercice. Ajoutez au brief un visuel newsletter-bandeau pour le canal site_hero, sans image de référence, avec seulement un sujet et un style. Lancez --dry-run : quelles alertes apparaissent, et comment les levez-vous ?
Voir la solution
- id: newsletter-bandeau
usage: produit
canaux: [site_hero]
sujet: "la lampe Orbe sur une table de chevet"
style: "photographie d'intérieur"Le dry-run affiche deux alertes : newsletter-bandeau : prompt sous-spécifié (composition, cadrage, lumière : au moins 2 sur 3) et newsletter-bandeau : visuel produit sans image de référence, le produit sera réinventé. On les lève en ajoutant references: [orbe] et au moins deux rubriques, par exemple composition: "lampe au tiers gauche, mur uni à droite pour le titre" et lumiere: "lumière de lecture chaude, pièce dans la pénombre".
- Je sais décrire une campagne dans un brief YAML validé
- Je distingue erreur bloquante et avertissement de studio
- Je sais pourquoi une session de chat par visuel
- Je ne réessaie que les erreurs transitoires
- J'ai lancé
make runet ouvertsorties/campagne/planche.html