Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
132 KiB
Module Studio — Génération IA d'images, vidéos et 3D
Statut : conception arrêtée le 2026-09-02, consolidée le 2026-09-13. Lots 0 à 4 codés le 2026-09-13 (branche
studio), écarts en décision 23. Restent avant le gate : infra du bucket, clé fal.ai, calibrage et prix (§9.4), puis le test du conservateur.Pour coder, lire d'abord le §8 : il rassemble, pour les lots 0 à 4, les prérequis, les étapes, ce qui les vérifie et la checklist. Les §0-§7 restent la trace de la conception et la source des pourquoi. La consolidation du 13/09 a reporté ici les décisions de immersif-frontiere-plan.md §5 (n°1, 4, 6), corrigé les contradictions laissées par les révisions successives, et tranché six points avec Thomas (décisions 14 à 19).
Deux versions, deux documents. V1 — la refonte de la Médiathèque : elle a désormais sa spec autonome et exécutable, ../v1-mediatheque-plan.md. Aucun appel à un modèle, aucune clé, aucun coût variable. Se livre seule, sans rien lire d'ici. V2 — ce document : le Studio — identité visuelle, génération, crédits, personnages, vidéo, 3D. Suppose la migration Postgres faite. MVP : escape game, images seulement.
Ce plan est la source de vérité sur les personnages (
Persona) : il remplace la section dual-persona de tts-pregenerated-plan.md et annule talking-head-plan.md.Maquettes — deux, complémentaires : · Spécification annotée, 10 plateaux avec le raisonnement et les références au code : https://claude.ai/code/artifact/09e2e8a4-04f5-4817-bfd0-08d08f6212dd · Démo navigable, menu complet fonctionnel, écrans inchangés signalés comme tels, facettes et panneaux réellement cliquables : https://claude.ai/code/artifact/7eda3ec0-a782-406a-8bfa-42995c4002d9
0. Ce que le module est, et ce qu'il n'est pas
Le produit n'est pas l'accès aux modèles. N'importe qui peut aller générer une image ailleurs. Le produit est la cohérence visuelle garantie à l'échelle d'un projet : qu'un conservateur qui ne sait pas prompter obtienne, en trois champs, une image raccord avec les quarante autres du même parcours.
Critère de réussite unique, qui prime sur la richesse fonctionnelle :
Installer un conservateur devant l'écran et le laisser générer 10 images cohérentes sans intervention. S'il faut prompter à sa place, l'UX a échoué.
Conséquences directes sur la conception :
- Le prompt libre n'est jamais le chemin par défaut.
- L'identité visuelle est saisie une fois, jamais retapée.
- Les boutons « Générer » vivent là où le contenu vit, pas dans un playground.
- Tout ce qui peut être décidé côté serveur l'est côté serveur.
1. État des lieux (Phase 1)
Exploré : manager-app, manager-service, mymuseum-visitapp, visitapp-web.
1.1 Front — manager-app
| Sujet | Réalité |
|---|---|
| Stack | Flutter web, 3.0.0+10 |
| État | Provider + ChangeNotifier. Contexte unique AppContext → ManagerAppContext (accessToken, instanceId, instanceDTO, clientAPI) |
| Routing | GoRouter mais plat : tout le back-office sous /main/:view, :view traduit en currentPosition par un switch de 13 case (main_screen.dart:621), puis re-switch sur elementToShow.type |
| Formulaires | Pas de Form/validation Flutter. 48 composants *_input_container.dart maison, chacun avec onChanged écrivant dans le DTO, un bouton Enregistrer qui POST le DTO entier |
| Design system | Pas de lib UI. Tokens const dans constants.dart:60-160 : kSurface/2/3, kInk/2/3, kLine/kLineSoft, kBrand, kSpace1..8, kRadius*, kTitleScreen/kTitleCard/kLabelField/kMetricLarge. Pas de thème sombre |
| Écran de référence | Screens/GuideIa/guide_ia_screen.dart (1053 l.) — StatefulWidget, _load() en addPostFrameCallback, onglets par int _tab, _loading/_saving, MessageNotification |
| Client API | manager_api_new/, édité à la main, jamais régénéré |
1.2 Back — manager-service
- .NET 8 / EF Core 9 / PostgreSQL. 22 contrôleurs,
XxxDatabaseServicepar entité. - Auth JWT (manager) +
X-Api-Key(visiteurs). 4 rôles en dur → 4 permissions → 4 policies (Security.cs). - Hangfire bien installé :
IBackgroundJobClientinjecté, jobs prenant un id, jamais un objet.SectionIndexingInterceptordéclenche la réindexation RAG à l'écriture — précédent d'automatisme. - Multilingue :
List<TranslationDTO> { language, value }en jsonb. - IA existante :
IAssistantService(Gemini 2.5-Flash-Lite via endpoint OpenAI-compatible),IEmbeddingService,IVectorStoreService. Modèle et clé en dur dansappsettings.json. - Facturation :
StripeService,StripeWebhookController,SubscriptionPlan,TrialLifecycleService. - Audit :
AuditLog { EntityType, EntityId, Action, UserId, InstanceId, Timestamp, OldValues, NewValues }.
1.3 Médiathèque
Resource { Id, Type(enum int), Label, InstanceId, Url, StoragePath, FileName, SizeBytes, Width?, Height?, Date*, AiIndex* }
— les AiIndex* servent au RAG, pas à la provenance : AiProvenance n'existe pas encore.
ResourceTypeva déjà jusqu'àImage360 = 11,Video360 = 12,Model3D = 13(livrés le 2026-09-12, verrouillés par un test). Le Studio n'ajoute rien à l'enum.- Stockage Firebase/GCS, chemin
pictures/{instanceId}/{resourceId}. - L'upload est fait par le navigateur, en direct, à deux endroits de
resources_screen.dart: la création (create,putData) et « Remplacer le fichier » (_replaceFile). Les deux passent parImageCompressor.dart(2560 px / q82, PNG à alpha conservé, 360 et 3D exclus). Le backend ne sait que supprimer (IResourceBlobService.DeleteAsync), et seulement siFirebase:StorageBucketest renseigné — il est vide (appsettings.json:46). ResourceController.Upload(POST /api/Resource/upload, base64 + watermark) est du code mort :System.Drawing.Commonne tourne pas suraspnet:8.0Linux, et la route n'écrit aucun blob.- ⚠️
Startup.cs:109-113fixeFormOptions.MemoryBufferThreshold = int.MaxValue: tout fichier reçu enIFormFileest tenu entier en RAM. Sans conséquence avec la décision 14, où aucun fichier ne traverse l'API ; à retirer avec la routeuploadmorte. - ⚠️ manager-app n'a pas de Firebase Auth (
firebase_core+firebase_storage, pas defirebase_auth) et écrit pourtant dans le bucket : les règles Storage sont donc très probablement ouvertes en écriture à qui connaît la config web. Aucunstorage.rulesdans le repo — à relever dans la console. Trou actuel, indépendant du Studio ; le lot 0 le ferme. - Pas de CDN, pas de thumbnails, pas de variantes, pas de versionnage.
- Quota stockage =
SUM(SizeBytes)par instance,413à l'upload.
1.4 Multilingue
10 langues (FR NL EN DE IT ES PL CN AR UK). LB absent des 4 repos — zéro occurrence.
Traduction assistée par POST /api/Ai/translate.
1.5 Offline
GET /api/Configuration/export/{id} collecte les ressources via
section.GetReferencedResourceIds(language) (implémenté sur les 13 sous-types). L'app télécharge
chaque resource.url localement, invalidation sur dateUpdate.
→ Un asset généré part offline sans rien de spécial, à condition d'être un Resource référencé
par un champ de section. Un asset validé mais non affecté n'atteindra jamais un visiteur hors ligne.
Trou existant : GuidedStep.ImageUrl est une URL absolue, pas un ResourceId — explicitement
exclu de la collecte (GuidedStep.cs:74). Or les étapes de parcours guidé sont le terrain de
l'escape game.
1.6 Quotas
Tout sur Instance, dupliqué depuis SubscriptionPlan : StorageQuotaBytes, AiTokensPerMonth,
AiTokensThisMonth, AiUsageMonthKey, trial capé à 300 000 tokens.
AiController.CheckQuota() : reset mensuel par comparaison de clé, 429, incrément après l'appel.
Une seule monnaie, deux compteurs cumulés, aucun journal ligne à ligne.
2. Décisions
| # | Question | Décision |
|---|---|---|
| 1 | Portée de l'identité visuelle | Une par instance (le musée) + surcharge optionnelle par configuration (parcours thématique, ex. Halloween). Cohérent avec Configuration.PrimaryColor/SecondaryColor/Languages qui existent déjà |
| 2 | Souveraineté | Champ de configuration déclaratif pour l'instant. La couche fournisseur rend le changement possible sans réécriture ; pas de chantier stockage EU aujourd'hui |
| 3 | Crédits | Monnaie dédiée, distincte des tokens IA. Griller son quota d'images ne doit pas priver les visiteurs du guide IA. Révisé le 2026-09-11 : crédits rechargeables à expiration 12 mois, pas de quota mensuel — voir §3.5 |
| 4 | Rôle valideur | Permission détachée (Manager.assetvalidation), pas un 5e rôle. Accordée d'office à InstanceAdmin, activable sur un ContentEditor |
| 5 | Brouillons | Crédits débités toujours (l'appel a coûté). Quota stockage compté seulement à la validation. Variantes non retenues supprimées à la fermeture du panneau ; balayage Hangfire à 30 j comme filet |
| 6 | Mention IA | Pas de gravure dans les pixels par défaut — irréversible, et laid sur une illustration de musée. Provenance en base + badge UI visiteur dans les langues du projet + métadonnées fichier. Gravure disponible en option par instance pour l'institution qui l'exigerait par écrit |
| 7 | Personnages | Un seul type d'objet, pas de Kind. Ce qu'un personnage sait faire vient des facettes remplies : VoiceId → il narre, SystemPrompt → il peut être guide, WakewordId → il est adressable en mains-libres. Le « visiteur choisit entre Viva et Marco » du plan TTS était un choix de voix déguisé en choix de guide. Modèle de référence : Bastogne War Museum |
| 7bis | Guide par visite | Possible, et c'est le même motif que le reste : Configuration.GuidePersonaId ?? Instance.GuidePersonaId. Un personnage peut donc narrer et répondre sur son parcours. Limite : en mains-libres, c'est le guide de l'instance qui répond — un wakeword est un modèle embarqué dans le build, et deux guides adressables demanderaient au visiteur de retenir deux noms |
| 7ter | Connaissances du guide | Configuration.GuideKnowledgeScope = Instance | Configuration. Aujourd'hui la recherche vectorielle ne filtre jamais par configuration (ContentEmbedding.ConfigurationId : « sert à privilégier la visite en cours au classement — jamais à filtrer ») : sur un escape game, le guide peut révéler une solution écrite dans un autre parcours |
| 8 | Wakeword | Propriété du canal, pas du personnage. Nom du guide libre par défaut ; contraint à un modèle OpenWakeWord disponible uniquement si l'instance active un canal mains-libres (lunettes, casque VR). Mobile / web / kiosk : push-to-talk, aucun wakeword |
| 9 | Talking head | Abandonné. Son lipsync repose sur enable_time_pointing de Google Cloud TTS, or le code tourne sur Gemini TTS qui ne fournit pas de timestamps. Remplacé par le portrait canon statique, puis une boucle vidéo (lot 10) |
| 10 | Narration attribuée | Retenue en V2 avec le lot TTS : NarratorPersonaId sur GuidedStep / GeoPoint / SectionArticle |
| 11 | Découpage de version | La refonte de la Médiathèque passe en V1 (elle corrige des défauts d'aujourd'hui et n'engage aucune clé API). Studio, identité visuelle, personnages, crédits : V2 |
| 12 | Gating | StudioEnabled implique IsAssistant — pas de Studio sans assistant. L'inverse est libre : un client peut avoir l'assistant sans le Studio, et il ne voit alors ni Guide IA › Personnages › Visage, ni l'identité visuelle, ni l'onglet Studio |
| 13 | Moteur de rendu | Propriété de l'identité visuelle (VisualIdentity.ModelKey), pas du gabarit. Deux modèles ne rendent pas pareil : en changer au milieu d'un projet casse la promesse. GenerationModel.RenderFamily distingue un remplacement bénin d'un changement destructeur |
| 14 | Chemin d'upload — 13/09 | URL d'envoi signée + ingestion serveur — révisé le même jour, remplace « tout par le serveur ». Le navigateur envoie directement chez Google, dans incoming/{instanceId}/{resourceId}, avec une URL V4 signée par l'API (15 min, un seul objet, taille bornée par x-goog-content-length-range), puis appelle POST /ingest : un ResourceIngestionService unique mesure la taille réelle, applique le quota, post-traite et range sous pictures/. Le Studio et le TTS passent par le même service. Les écritures clientes sont fermées dans les règles Storage. Les gros fichiers (vidéo 360, GLB) ne traversent jamais le VPS. Pourquoi pas « tout par le serveur » : même fermeture du bucket, mais chaque octet à travers l'API — RAM, timeouts, débit du VPS. Reporte la décision n°1 d'immersif-frontiere-plan §5 |
| 15 | Périmètre du MVP (lot 4) — 13/09 | Deux points de départ : « depuis le texte » et « depuis une image » (sourceFidelity = none | subject), objets et lieux seulement. « Modifier une image » (subjectPose), framing et tout cas de personne réelle : après le gate |
| 16 | Prix des crédits — 13/09 | Différés. Le lot 1 livre le mécanisme sans aucun prix ; les crédits sont posés à la main (Grant depuis SuperAdmin). Unité, packs et CreditCost se fixent à la fin du lot 3, sur les coûts fal.ai mesurés. Recharge Stripe self-service : après le gate du lot 4 |
| 17 | Expiration des crédits — 13/09 | Un solde, une date. Chaque Grant repousse l'expiration de tout le solde à maintenant + 12 mois. Pas de lots FIFO |
| 18 | Plafonds d'upload — 13/09 | Par type, en configuration : Image 30 Mo · Image 360 80 Mo · Audio 100 Mo · PDF/Word/PowerPoint 50 Mo · Vidéo 500 Mo · Vidéo 360 1 Go · Modèle 3D 200 Mo. Au-delà d'une vidéo de 500 Mo : YouTube/Vimeo, que SectionVideo supporte déjà |
| 19 | Contenu éditorial — 13/09 | Un catalogue v0 (styles, règle d'assemblage, 3 gabarits) est écrit au §9 comme seed ; il est calibré à la fin du lot 3 par des générations réelles, avant d'ouvrir le lot 4 |
| 20 | Lignage | Resource.Origin (Uploaded | Generated | Derived) et Resource.SourceResourceId en colonnes typées ; AiProvenance jsonb garde le détail. Reporte la décision n°4 d'immersif-frontiere-plan §5. Posé au lot 0, puisque l'ingestion l'écrit |
| 21 | Contrat fournisseur | ProviderResult multi-fichier, délais et relances par GenerationKind. Le job composite (parent + N enfants) est réservé à la 3D. Reporte la décision n°6 d'immersif-frontiere-plan §5 |
| 22 | Plafond dur et dotation de plan — 13/09, lot 1 | Retirés. Avec des crédits prépayés (décision 17), le solde est le plafond : un « plafond dur organisation » n'a plus rien à borner. Seul reste StudioPerUserDailyCap, en crédits. SubscriptionPlan.HasStudio / StudioCreditsGranted et Instance.StudioProviderRegion ne sont pas créés tant que rien ne les lit (prix différés, décision 16 ; région déclarative) |
| 23 | Écarts des lots 2 à 4 — 13/09 | Fournisseur interchangeable : IGenerationProvider (soumission, sondage, lecture du webhook) + GenerationModel.ProviderKey en base ; changer de fournisseur = une classe et une ligne de catalogue. Une requête par variante (FLUX.2 pro n'a pas de num_images). Référence source avant celles de l'identité dans l'ordre d'envoi. Migration unique AddStudioGeneration pour les lots 2 et 3. Non livrés : preview, GET /jobs, cancel, reject, regenerate, l'interrupteur UI de CanValidateAssets. Pas de target serveur : l'onglet « Générer » vit dans le sélecteur (showSelectResourceModal, mode sélection) et la ressource validée revient au champ qui l'a ouvert — donc absent du bouton « Ajouter » de la Médiathèque, qui relève du lot 5. Surcharges d'identité en puces, pas en onglets ; ModelKey non exposé (un seul modèle). GuidedStep.ImageUrl conservée et remplie par le client avec ImageResourceId (motif GeoPoint) : visitapp-web n'a rien à changer, mymuseum-visitapp lit l'id pour l'hors-ligne, tablet-app n'a pas d'étapes. CreditCost = 10 provisoire jusqu'au calibrage |
| 25 | Casting visiteur — 15/09 | Retenu — lève le « hors périmètre » de §3.8. ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured } ne porte que l'ordre et la mise en avant : l'appartenance reste calculée depuis les narrateurs assignés. Un personnage ne s'assigne qu'à un seul endroit — le contenu ; l'écran casting est une vue, jamais une seconde saisie. Rien ne s'affiche côté visiteur sans Configuration.IsCastingShownToVisitor (défaut off). Lot 8bis |
3. Conception
3.0 Où le module se branche — révisé le 2026-09-01
Cette section corrige l'intention initiale (« l'onglet Studio = médiathèque »). Elle a été réécrite après lecture du code : il n'y a pas deux écrans de médiathèque à faire cohabiter, il n'y en a qu'un.
Fait décisif : showSelectResourceModal embarque ResourcesScreen
(select_resource_modal.dart:24, avec isSelect: true). L'onglet Ressources et le sélecteur de
champ de n'importe quel éditeur sont le même widget, rendu dans deux contextes. Toute l'app
passe par ce point unique pour choisir une image.
Conséquence n°1 — un seul point d'accroche pour la génération. Ajouter « Générer » dans
ResourcesScreen / showNewResource le fait apparaître d'un coup dans les 13 types de section,
les POI, les étapes de parcours et l'onglet Ressources. Aucun éditeur à modifier.
La seule différence entre les deux contextes est un paramètre :
| Ouvert depuis | Cible | Effet |
|---|---|---|
Un champ d'éditeur (ResourceInputContainer) |
target { kind, id, field } connue |
Le titre et la description de la fiche alimentent le prompt ; la validation affecte directement au champ |
| L'onglet Ressources | aucune | Prompt sans contexte de fiche ; l'image validée tombe dans la bibliothèque |
Même écran, même modale, un target nullable — exactement comme isSelect / isAddButton aujourd'hui.
Conséquence n°1bis — le TTS se découvre par le même crochet. SectionArticle.ArticleAudioIds
est un champ ressource : son sélecteur gagne donc l'onglet « Générer », qui pour un audio veut
dire « Générer depuis le texte ». Le client découvre le TTS là où il cherchait un fichier audio,
sans avoir à savoir que la fonction existe. La console de masse (Configuration › Personnages, §3.8)
reste pour traiter 40 contenus d'un coup — découverte par contenu, volume par console.
⚠️ Il faut savoir si l'audio correspond encore au texte. Corriger une coquille rend l'audio
silencieusement faux. tts-pregenerated-plan prévoit la régénération sur changement de voix ou de
persona, pas sur changement de texte : stocker une empreinte du texte au moment de la génération,
sinon l'état « à jour » mentira. Et avertir à l'enregistrement du texte, pas seulement proposer
la régénération après coup.
Conséquence n°2 — pas de seconde médiathèque. Un asset validé est une Resource. Deux
grilles sur la même table, ce serait un défaut d'interface, pas une fonctionnalité. La répartition
est donc :
| Surface | Contenu | Statut |
|---|---|---|
| Médiathèque (ex-Ressources) | La bibliothèque unique, refondue — voir §3.0bis | refonte |
| Studio › Identité visuelle | L'écran structurant | neuf |
| Studio › Personnages | Registre des personnages : guide et figures de parcours, avec leurs facettes visage et voix — voir §3.8 | neuf |
| Studio › À valider | Les brouillons — ils ne sont pas encore des Resource, donc ils n'ont pas leur place dans Ressources. Liste courte : jobs abandonnés, et générations d'un ContentEditor sans la permission de validation |
neuf |
| Jauge du pied de menu → popover | Les chiffres de crédits : disponibles, solde, réservés, expiration, votre jour. Accessible à tous | enrichi |
| Abonnement | Le détail et le journal d'usage exportable. Pas de sous-onglet Studio dédié | enrichi |
Décidé le 2026-09-01 : pas de sous-onglet « Usage & crédits ». Il dupliquerait une surface qui doit exister de toute façon. Un clic sur la jauge ouvre un popover avec les chiffres — rien de monétaire, donc accessible à un
ContentEditorqui n'a rien à faire dans la facturation. Le détail et le journal vont dans Abonnement, où vivent déjà le plan et le portail Stripe : un journal qu'on exporte en CSV sur une plage de dates est une page, pas une popup.⚠️ Ça bute sur un défaut existant : l'entrée Abonnement n'apparaît que si
subscriptionPlanId == "plan-essentiel"(main_screen.dart:712-714au 13/09) — une instance Premium ne voit pas son propre écran d'abonnement. À corriger dans le lot 1. L'écran s'annonce d'ailleurs lui-même en commentaire comme « the future home of the paid AI request quota add-on » (subscription_screen.dart:20).
3.0bis Refonte de la Médiathèque — décidé le 2026-09-01
Fusionner dans un écran déjà encombré empire le problème. L'écran actuel a des défauts mesurables, indépendants du Studio :
| Aujourd'hui | Où |
|---|---|
| Tuiles carrées de 160 px, ~300 d'un bloc, sans ordre | resource_body_grid.dart:78 — SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 160, childAspectRatio: 1.0) |
Chips de type en Wrap — un seul axe de filtre |
resource_body_grid.dart:133 |
| Ni tri, ni groupement, ni sélection multiple, ni action en lot | absent |
Popup 520 px, borderRadius: 20, aperçu encadré d'une bordure grise de 3 px à borderRadius: 30 — hors du système (5/8/10) |
show_resource_popup.dart:20,50-55 |
| Champ Label au-dessus de l'aperçu : on nomme avant d'avoir vu | show_resource_popup.dart:36 |
Quatre RoundedButton de même poids dans un Wrap — destructif à côté de primaire, ça se stacke |
show_resource_popup.dart:67-110 |
Aucune métadonnée affichée, alors que SizeBytes, FileName, DateCreation, Type sont en base |
— |
🐛 Le téléchargement force .json en dur quel que soit le type : un PNG se télécharge en label.json |
show_resource_popup.dart:91 |
⚠️ FileName existe sur l'entité mais n'est pas dans Resource.ToDTO() — le front ne peut pas nommer correctement le fichier |
Resource.cs |
Proposition (maquettée, plateaux 04 et 05) :
- Renommage Ressources → Médiathèque.
- Rail de facettes cumulables avec compteurs : Type · Usage (utilisée / jamais utilisée) · Origine (importée / générée par IA) · Configuration. On empile, on ne choisit plus un seul axe.
- Groupement par mois, tri par date / nom / poids / usage, bascule grille ↔ liste.
- Compteur d'usages sur la vignette (« 2 usages » / « libre ») — l'information qui sert à décider quoi supprimer, sans rien ouvrir.
- Sélection multiple et actions en lot. Aujourd'hui, supprimer 23 orphelines demande 23 popups.
- Le détail passe de la modale au panneau latéral : aperçu d'abord, nom ensuite, métadonnées réelles, bloc provenance pour les images générées, et une hiérarchie de boutons — Enregistrer en primaire, destructif isolé et désactivé tant que la ressource est utilisée.
« Utilisée dans » — ✅ livré le 2026-09-02 avec la V1. Le contrat réel (forme enrichie de
usage-map, sectionId, et quatre pièges dont un s'est révélé faux) est dans
../v1-mediatheque-plan.md §2.2, qui fait foi. La version de conception
qui figurait ici a été retirée le 13/09 : elle était périmée sur ces deux points.
Le filtre « jamais utilisée » vaut pour toutes les ressources, pas seulement les générées : c'est lui qui rend visible qu'un asset rattaché à aucun champ n'atteindra jamais un visiteur — et il y en a déjà aujourd'hui, qui occupent du quota sans que rien ne le signale.
3.1 Vue d'ensemble
manager-app manager-service fal.ai
─────────── ─────────────── ──────
[Générer] dans l'éditeur ──► POST /api/Studio/generate
(2-3 champs métier) ├─ résout l'identité visuelle
├─ assemble le prompt (serveur)
├─ réserve les crédits (ledger)
├─ crée GenerationJob(Queued)
└─ Hangfire.Enqueue ────────────► submit
◄── 202 { jobId }
polling GET /jobs/{id} ◄── webhook
├─ télécharge les octets
├─ écrit dans studio-drafts/
├─ crée GeneratedAsset(Draft) ×N
└─ débite les crédits
grille de variantes ◄── Succeeded
[Valider] ──► POST /assets/{id}/validate
├─ ResourceIngestionService (origin: Generated)
│ ├─ contrôle quota stockage ◄─ le mur tombe ICI
│ ├─ post-traite (ImageSharp 2560/q82)
│ └─ écrit pictures/{instanceId}/{resourceId}
├─ crée Resource + provenance
└─ affecte au champ cible
3.2 L'identité visuelle de projet
L'objet central. Défini une fois, injecté côté serveur dans chaque prompt, jamais retapé.
VisualIdentity
Id, InstanceId, ConfigurationId? -- null = identité de base du musée
Name, IsDefault
StyleKey -- catalogue serveur : illustration,
-- photoréaliste, gravure ancienne, aquarelle…
Era, Region, Materials, LightingMood -- contraintes de contexte (enum + texte libre)
ModelKey -- le moteur qui rend cette identité. Changer de moteur
-- au milieu d'un projet casse la cohérence : c'est un
-- réglage d'identité, pas de gabarit
Palette jsonb List<string> -- hex
Exclusions jsonb List<string> -- puces prédéfinies + libre
ReferenceResourceIds jsonb List<string> -- 1 à 5, images du lieu / charte / ambiance
PromptPreamble text -- calculé au save, jamais saisi
NegativeFragment text -- calculé au save
Version int -- incrémenté à chaque save
Nommage : l'onglet porte le nom de l'objet auquel l'identité appartient — le nom de l'instance (« Fort Saint-Héribert »), puis le nom de la configuration. Jamais un mot de catégorie : tous les clients ne sont pas des musées, et « Identité du musée » serait faux chez la moitié d'entre eux.
Une identité de base, toujours. Une par instance, créée vide au premier accès, non supprimable : c'est le repli de toute configuration qui n'a pas la sienne.
Résolution : à la génération, Resolve(instanceId, configurationId) → identité de la
configuration si elle existe, sinon identité de base de l'instance.
Pas de fusion partielle, mais un lien visible. Une identité de configuration est une copie complète, créée par un bouton « Partir de [nom de l'instance] », puis éditée librement.
Pourquoi pas d'héritage : la sémantique « champ vide = hérité » est un piège dans une UI destinée à un conservateur. Une palette vide voudrait dire « hérite » ou « pas de contrainte » ? La fusion partielle produit des résultats que l'utilisateur ne peut pas expliquer.
Le lien avec l'identité de base ne disparaît pas pour autant — il devient manuel et par bloc. Chaque bloc (style, contexte, palette, exclusions, références) porte :
- un badge « modifié » dès qu'il diverge de l'identité de base ;
- une action « Reprendre depuis [nom de l'instance] » qui resynchronise ce bloc seul.
On garde la prévisibilité de la copie sans perdre la traçabilité de la parenté.
CopiedFromIdentityId + CopiedFromVersion portent la parenté en base.
Version n'est pas décoratif. « Cohérent avec les 40 autres » n'est vrai que si on peut dire
lesquelles ont été produites sous quelle version de l'identité. La grille Studio doit pouvoir
signaler « 12 images produites sous une version antérieure ».
3.3 Gabarits métier
Table serveur, éditable par SuperAdmin, jamais en dur dans le code.
GenerationTemplate
Key -- historical-object · scene-evocation · puzzle-decor
-- character-portrait · avatar · before-after
Label jsonb List<TranslationDTO>
Kind -- Image | Video | Model3D
Fields jsonb List<TemplateField> -- 2 à 3, jamais plus
PromptTemplate text
DefaultModelKey, DefaultAspectRatio, DefaultVariantCount
AppliesTo jsonb -- types de cible où le bouton apparaît
TemplateField { key, label(trad), type: text|enum|number, required, options[], hint }
Assemblage du prompt, côté serveur, dans cet ordre :
- Préambule du modèle (
GenerationModel.Params) VisualIdentity.PromptPreamble— style, époque, région, matériaux, lumière, palette en mots- Corps du gabarit, champs métier substitués
- Contexte du contenu — titre + description de la fiche (POI, étape, objet), tronqués
NegativeFragment— exclusions + « pas de texte dans l'image » par défaut- Le personnage, s'il en apparaît un — voir ci-dessous
- Images de référence, dans cet ordre de priorité : vues du canon → refs de l'identité (1-5) → image source
L'utilisateur ne voit jamais 1, 2, 5, 7. Le mode avancé rend 3 et 4 éditables.
EffectivePrompt est toujours stocké, quoi qu'il arrive.
« C'est Léon qui est dans le donjon » — le personnage dans une génération
C'est le mécanisme qui fait qu'un personnage récurrent reste le même d'une image à l'autre, et il doit être exposé dans le panneau de génération : un sélecteur « Qui apparaît dans l'image ? », alimenté par le registre. Sans lui, le canon existe en base et ne sert à rien.
Deux injections, pas une — et les deux sont nécessaires :
| Ce qu'on envoie | Pourquoi |
|---|---|
| Les vues du canon en images de référence | Le visage, la carrure, le costume tel qu'il a été validé |
Les Params (époque, costume, âge, métier) en texte |
Une image de référence ne porte pas toujours « capote d'officier » — le modèle a besoin de la nommer |
GenerationTemplate gagne un AcceptsPersona : le sélecteur n'apparaît que sur les gabarits où un
personnage a du sens (portrait, évocation de scène, décor habité), pas sur « objet d'époque ».
« Point de départ » — l'image source, généralisée
Ce qui cadre déjà une génération est du niveau projet : style, époque, région, matériaux, lumière, palette, exclusions, les 1-5 images du lieu, plus le titre et la description de la fiche. Rien ne dit « ce coffre-là, celui de la collection ». D'où un troisième type d'entrée, à exposer dans le panneau — trois points de départ, et un curseur qui dit ce qu'on garde :
| Point de départ | sourceFidelity |
Pour quoi |
|---|---|---|
| Depuis le texte | — | l'identité et la fiche suffisent (décor inventé) |
| Depuis une image | none | subject |
la vraie pièce, redessinée dans le style du projet |
| Modifier une image | subjectPose |
garder le sujet, changer l'époque ou le costume |
| — | framing |
l'avant/après : même angle, autre époque (§3.9) |
Trois entrées distinctes, à ne jamais confondre :
- Identité visuelle = le style du projet.
- Image source = cet objet-ci, la pièce réelle. Une photo, une fois.
- Personnage = cette figure récurrente, définie une fois, réutilisée. Le canon.
- Les trois coexistent : Léon devant le vrai coffre, en gravure ancienne.
MVP (décision 15) : seules les deux premières lignes du tableau — « depuis le texte » et « depuis une image » en
none|subject, sur des objets et des lieux.subjectPose,framinget tout ce qui touche une personne réelle attendent le gate du lot 4. Le contrat d'API garde les quatre valeurs ; le panneau n'expose que les deux premières et le serveur refuse les autres (400).
⚠️ Personnes identifiables. « Modifier une image » sur la photo d'une personne réelle — un agent d'accueil, un descendant — est un traitement de ressemblance, chez des institutions publiques belges et luxembourgeoises. Consentement écrit, et tracé. La réponse produit est de rediriger vers les personnages : un avatar généré n'est le portrait de personne, et son canon donne une cohérence qu'une photo retouchée ne donnera jamais sur quinze étapes. Recommandé : réserver « Modifier une image » aux objets, lieux et documents par défaut, et faire du cas « personne » une action explicite avec case de consentement.
⚠️ Deux personnages coûtent la cohérence du style
Le plafond de références est celui du modèle (MaxReferenceImages, 10 pour FLUX.2). Le calcul est
sans appel :
| Composition | Canon | Identité | Source | Total |
|---|---|---|---|---|
| Aucun personnage | 0 | 4 | 1 | 5 / 10 |
| Un personnage (4 vues) | 4 | 4 | 1 | 9 / 10 |
| Deux personnages (4 + 2 vues) | 6 | 2 — tronquée | 1 | 9 / 10 |
Sur FLUX.2, deux personnages à 4 vues font sauter les références d'identité — donc le style dériverait exactement sur l'image la plus ambitieuse de la série.
⚠️ Correction du 2026-09-02. Ce plan a d'abord proposé de basculer sur Nano Banana Pro (14 références) pour faire tenir deux personnages. C'est une mauvaise réponse : deux modèles différents dans un même projet ne rendent pas pareil, même prompt et mêmes références. On aurait résolu un problème de budget en cassant la promesse centrale du module.
Ordre de dégradation, quand le budget ne rentre pas :
| # | On sacrifie | Coût | Pourquoi dans cet ordre |
|---|---|---|---|
| 1 | Vues de canon : 4 → 2, portrait d'abord | un peu de ressemblance | Deux personnages tiennent alors en 2+2+4+1 = 9/10 sur le modèle du projet |
| 2 | Références d'identité, tronquées | le style dérive | Plus grave que le point 1 — le style est la promesse |
| 3 | Changer de modèle | toute la série devient incohérente | Jamais en silence. Acceptation explicite de l'utilisateur, et l'écart est tracé |
Le modèle est une propriété de l'identité visuelle, pas du gabarit. VisualIdentity.ModelKey :
« cette identité est rendue par tel moteur ». Le gabarit peut suggérer un modèle, il ne l'impose
pas. Et GenerationModel gagne un RenderFamily — deux modèles d'une même famille sont
interchangeables, d'une famille à l'autre on casse la cohérence.
Conséquence côté Médiathèque : une image produite par un autre moteur que celui de son identité
est un écart de cohérence au même titre qu'une version d'identité périmée, et doit se signaler
pareil (badge, et proposition de régénérer). AiProvenance.modelKey le permet déjà.
À trois personnages, l'identité de chacun décroche quel que soit le modèle : limite réelle, à assumer côté scénario. Les échappatoires restent 2 vues par personnage, ou la composition en deux passes — générer A dans la scène, puis ajouter B en modification de cette image.
« Faire vivre un lieu » — la vidéo à partir du réel (décidé le 2026-09-04)
Le text-to-video pur est écarté : il invente la géométrie du lieu, ce qui est disqualifiant en
patrimoine. Le lieu réel reste la base, l'IA n'ajoute que le mouvement — c'est exactement
« Point de départ » ci-dessus, transposé à la vidéo, avec sourceFidelity: framing : le modèle n'a
pas à inventer l'architecture, il l'a sous les yeux.
Rien de neuf en base. GenerationTemplate.Kind = Video existe déjà, sourceFidelity aussi. Un
gabarit de plus, pas un mécanisme de plus.
Où ça vaut vraiment quelque chose, et où ça n'en vaut pas. Pas « faire vivre un bâtiment » en général : là où le lieu a des archives, une vraie photo d'époque bat une boucle générée, parce qu'en patrimoine la crédibilité est la valeur. Le cas qui gagne est celui où il ne reste rien : un bâtiment disparu, un métier éteint, un site réduit à ses fondations. Là, huit secondes plausibles battent un paragraphe, et elles ne concurrencent aucune archive. Cadrer la vente là-dessus.
Deux voies techniques, et elles ne vont pas au même endroit :
| Voie | Ce que ça demande au client | Verdict |
|---|---|---|
| Image-to-video — photo du lieu (ou sa version d'époque déjà générée) + gabarit de mouvement | Trois champs | Dans le produit |
| Motion transfer — le médiateur mime le geste, le mouvement est transféré sur le personnage | Un tournage, un consentement écrit, son archivage | En prestation Unov, pas dans le produit |
Le motion transfer donne un mouvement plus juste, et c'est la voie préférée qualitativement. Mais il échoue le critère de réussite du §0 par construction — « installer un conservateur devant l'écran et le laisser produire sans intervention » ne survit pas à « d'abord, filmez quelqu'un ». À 39-179 €/mois, aucun client n'a l'équipe pour ça. Il se vend, il ne se livre pas en self-service.
Contraintes de production — la partie qui ne périmera pas (au-delà, le résultat décroche) :
- 6 à 10 secondes, pensées pour la boucle, fondu aux extrémités
- une seule personne à l'écran
- mouvements simples, caméra calme
- plan large ou moyen, jamais de gros plan sur les mains ou le visage
Ces quatre lignes valent mieux qu'un nom de modèle : elles décrivent le domaine de validité de la
technique, pas d'un fournisseur. Le ProviderModelId vit en base (§3.6) et aura tourné plusieurs
fois d'ici la livraison.
Gabarits de mouvement, pas de prompt libre — même règle que partout ailleurs dans Studio : le conservateur choisit, il ne décrit pas.
⚠️ Consentement. Le motion transfer part du corps d'une personne réelle : c'est un traitement de ressemblance, au même titre que « Modifier une image » sur une personne (voir l'encadré ci-dessus). Consentement écrit, tracé, et archivé avec l'asset — obligation qui pèse sur la prestation, ce qui est une raison de plus de ne pas l'ouvrir en self-service.
⚠️ Mention IA plus visible qu'ailleurs. Une figure humaine en mouvement dans un lieu réel n'est pas une illustration décorative : le badge du §3.11 suffit juridiquement, mais certaines institutions refuseront le principe même. À valider auprès d'un client réel, pas seulement techniquement.
Contenu 2D pour écrans. Ne remet pas en cause la décision de rester sur des personnages stylisés en VR (v2/vr-quest-unity-plan.md).
Non tranché : est-ce que ça a l'air crédible ? Tout le reste en dépend, et ça ne se décide pas sur le papier. Test à faire avant d'écrire la moindre ligne d'UI — une photo d'un bâtiment du Fourneau, sa version d'époque, un personnage en boucle de 8 s. Quelques euros, une soirée. Si ça décroche, cette section saute.
3.4 Cycle de vie d'un asset
Draft ──validate──► Validated ──référencé par une section──► Publié (dérivé)
│
└─ rejeté / panneau fermé / balayage 30 j ──► supprimé (blob + ligne)
Publié n'est pas une colonne, c'est un calcul. Un asset marqué « publié » mais rattaché à aucun
champ de section n'atteint aucun visiteur — la collecte offline passe par GetReferencedResourceIds.
Une colonne mentirait. L'UI affiche donc trois états, dont le troisième est dérivé de l'usage réel.
Stockage :
| État | Emplacement | Compté dans le quota stockage | Servi au visiteur |
|---|---|---|---|
| Draft | studio-drafts/{instanceId}/{jobId}/{assetId} |
non | non — pas d'URL publique, servi au back-office par GET /api/Studio/assets/{id}/content authentifié |
| Validated | pictures/{instanceId}/{resourceId} |
oui | oui |
Le point qui rend la règle vraie : une URL Firebase à jeton est publique pour qui a le lien. Un brouillon dans le bucket normal serait servable. D'où le préfixe séparé et l'endpoint proxy.
À la validation, le serveur passe le brouillon à ResourceIngestionService, le même chemin qu'un upload
manuel (décision 14) : contrôle du quota stockage (413 ici, pas à la génération) → post-traitement
2560 px / q82 (ImageSharp, pas System.Drawing) → écriture sous pictures/ → Resource avec
Origin = Generated et sa provenance → affectation au champ cible s'il y en avait un. Le brouillon est
ensuite supprimé de studio-drafts/.
Purge en deux temps : suppression immédiate des variantes non retenues à la fermeture du panneau
(POST /assets/discard), et balayage Hangfire à 30 jours comme filet pour les jobs abandonnés —
onglet fermé, navigateur planté. Précédents en place : AuditLogPurgeService, VisitorQuestionPurgeService.
3.5 Crédits et garde-fous
Instance += StudioEnabled, StudioCreditsBalance, StudioCreditsExpireAt,
StudioPerUserDailyCap
-- plafond dur, dotation de plan et région : retirés (décision 22)
CreditLedger (append-only, exportable CSV)
Id, InstanceId, UserId, GenerationJobId?, Kind, Amount, BalanceAfter,
ModelKey, CreatedAt, Note
Kind: Reserve | Charge | Refund | Grant | Expire
Révisé le 2026-09-11 — deux changements, tous deux gratuits maintenant et coûteux après la première migration.
a)
ImageCredits*→StudioCredits*. Une 3D coûte 10 à 50 fois une image, et un monde généré n'est pas une image du tout. Le nomImageCreditsaurait menti dès le lot 11.GenerationModel.CreditCostporte déjà le coût par modèle : rien d'autre à changer côté tarification.b) Crédits rechargeables à expiration 12 mois, au lieu d'un quota mensuel. Le reset mensuel suppose un usage linéaire. Un musée ne travaille pas comme ça : il génère 200 images en trois semaines pour préparer une expo, puis rien pendant cinq mois. Un forfait mensuel se gaspille dix mois sur douze — le client a le sentiment de payer pour rien, et la capacité provisionnée ne sert pas. Le modèle rechargeable est aligné sur l'usage réel et sur un coût qui est à l'acte.
➡️ Disparaissent :
ImageCreditsPerMonth,ImageCreditsThisMonth,ImageCreditsMonthKey, et leKind: MonthlyReset. Apparaissent :StudioCreditsBalance,StudioCreditsExpireAt, et unKind: Expirepour tracer la péremption dans le journal. LeCreditLedgerencaisse le modèle sans changement de structure — il était déjà append-only avec unKind: Grant.Tranché le 2026-09-13 (décisions 16 et 17). Ni prix ni recharge Stripe avant le gate du lot 4 : les crédits sont posés par un
Grantmanuel depuis l'écran SuperAdmin. Le jour venu, un achat de crédits est un paiement ponctuel —checkout.session.completedsuffit (le webhook ne gère aujourd'hui que lui etinvoice.payment_failed,StripeWebhookController.cs:62-65) ; reste à écrire le mapping session →Kind: Grant.Règle d'expiration : un solde, une date. Chaque
GrantfaitStudioCreditsExpireAt = maintenant + 12 moispour tout le solde. Un job Hangfire quotidien remet à zéro les soldes échus en écrivant une ligneKind: Expiredu montant perdu. Une instance qui a des réservations ouvertes est sautée et reprise au passage suivant.Le critère qui sépare les deux monnaies, pour ne plus se reposer la question : consommé en temps réel par le visiteur (chat, vocal → quota IA, reset mensuel, attribut du palier) contre produit une fois par le client (images, 3D, scènes, vidéo, TTS pré-généré → crédits Studio, rechargeables, add-on). Détail et vérification dans le code : immersif-frontiere-plan.md §2bis.
Le plafond dur n'est pas le bon outil contre le stagiaire. Le scénario « vider un budget en une après-midi » est un problème par utilisateur, pas par organisation : le solde est justement là pour être dépensé. Le garde-fou est donc :
StudioPerUserDailyCap— N crédits engagés par utilisateur et par jour, réservations en cours comprises. Le plafond organisation a disparu avec les crédits prépayés (décision 22).
Réservation en deux temps. CheckQuota avant / incrément après suffit à un appel LLM bloquant de
2 s. Il ne suffit pas à dix jobs Hangfire de 40 s lancés en parallèle : les dix contrôles passent avant
le premier débit. Donc : Reserve à l'enqueue, Charge à la complétion (ajusté au coût réel),
Refund à l'échec. Solde disponible = StudioCreditsBalance − réservations ouvertes.
Le Reserve se fait sous verrou de ligne sur Instance (SELECT … FOR UPDATE), dans la même
transaction que l'écriture du ledger — sans quoi le problème des dix jobs parallèles revient par la
base au lieu de venir de Hangfire.
Le coût estimé vient de GenerationModel.CreditCost × VariantCount, affiché avant le lancement.
Le journal exportable est une projection CSV de CreditLedger — c'est le document que les
institutions publiques demanderont.
3.6 Couche fournisseurs
public interface IGenerationProvider
{
string Key { get; }
GenerationKind[] SupportedKinds { get; }
Task<ProviderSubmission> SubmitAsync(GenerationRequest req, CancellationToken ct);
Task<ProviderResult> PollAsync(string providerRequestId, CancellationToken ct);
ProviderResult ParseWebhook(string rawBody); // après vérification de signature
}
public enum GenerationKind { Image, Video, Model3D, SceneProp, Scene3D } // en fin, jamais au milieu
public record ProviderResult(
ProviderStatus Status, // Running | Succeeded | Failed
IReadOnlyList<ProviderFile> Files, // multi-fichier dès le départ (décision 21)
string Error);
public record ProviderFile(string Url, string ContentType, string Role);
// Role : "image", "preview", "glb", "texture", "collider"…
Délais par kind, en configuration (Studio:Timeouts:{Kind}) : Image — relance de secours à
+90 s, abandon à 10 min ; Video — +5 min, abandon à 30 min ; kinds 3D — fixés au lot 11. Le polling
front à 2 s ne vaut que pour Image.
FalAiGenerationProvider est la seule implémentation au départ. Le catalogue vit en base, pas
dans appsettings.json :
GenerationModel
Key -- "image-default", "image-character", "image-vector",
-- "video-default", "3d-default"
ProviderKey -- "fal"
ProviderModelId -- "fal-ai/flux-2-pro" ← la seule chaîne qui change quand le marché bouge
ReferenceProviderModelId -- "fal-ai/flux-2-pro/edit" : dès qu'une image de référence part,
-- c.-à-d. presque toujours, puisque l'identité en porte 1 à 5
-- (ids vérifiés sur fal.ai le 2026-09-13)
Kind, CreditCost, MaxReferenceImages, SupportsImageToImage, IsEnabled
RenderFamily -- deux modèles d'une même famille sont interchangeables sans casser
-- la cohérence d'un projet ; d'une famille à l'autre, non
Params jsonb
Cibles au moment de la rédaction, à ne jamais écrire dans le code :
| Rôle | Modèle | Note |
|---|---|---|
| Image par défaut | FLUX.2 [pro] | jusqu'à 10 images de référence — mesuré le 15/09 : 422 « At most 10 image URLs are allowed » au-delà ; bonne préservation de style |
| Personnages et scènes narratives | Nano Banana Pro / Gemini 3 Pro Image | jusqu'à 14 entrées de référence |
| Texte lisible / vectoriel | Recraft V3 | |
| Vidéo par défaut | Kling 3.0 | clips 6-8 s, pensés pour la boucle |
| Vidéo avec audio natif | Veo 3.1 | |
| 3D — 1 image | Hi3D (hitem3d/hi3d/image-to-3d) ou Tripo3D |
objets isolés, export GLB, ~0,02–0,05 $ |
| 3D — plusieurs vues | Meshy v7 (meshy/v7/multi-image-to-3d) |
2 à 4 photos du même objet, ~1,20 (1,40 en ultra) — voir décision 24 et lot 11 |
StudioProviderRegion sur l'instance est déclaratif aujourd'hui ; il devient un critère de sélection
de provider le jour où une région alternative existe.
La clé fal.ai ne va pas dans appsettings.json — le fichier versionné contient déjà les clés
Stripe, Gemini, Resend et OpenWeather en clair. Une clé facturée à la consommation y serait d'un autre
ordre de risque. Variable d'environnement, injectée par le volume /etc/managerservice.
3.7 Flux asynchrone
POST /api/Studio/generate
→ validations, Resolve(identité), assemblage du prompt, Reserve(crédits)
→ GenerationJob(Queued)
→ _jobs.Enqueue<IStudioGenerationService>(s => s.RunAsync(jobId)) -- id, jamais l'objet
→ 202 { jobId, estimatedCredits }
Hangfire worker
→ provider.SubmitAsync → ProviderRequestId, Status=Running
→ BackgroundJob.Schedule(PollFallbackAsync(jobId), +90 s) -- filet si le webhook se perd
POST /api/StudioWebhook/fal (signature ED25519 vérifiée — voir ci-dessous)
→ idempotent sur request_id, Enqueue(FetchResultAsync(jobId)), 200 immédiat
Hangfire FetchResultAsync
→ télécharge les ProviderFile, écrit studio-drafts/, crée GeneratedAsset ×N
→ Charge(crédits réels), Status=Succeeded -- ou Refund, Status=Failed
GET /api/Studio/jobs/{id} polling front à 2 s tant qu'un job est ouvert à l'écran
Polling, pas SSE, en V1. Écrans mono-utilisateur, jobs de 20-60 s, et le backend n'a aucun précédent SSE ni de canal serveur→back-office (MQTT sert aux devices). SSE est une amélioration ultérieure, pas un prérequis.
Vérification du webhook fal.ai — ce n'est pas un HMAC. Vérifié dans la doc fal le 2026-09-13 ; la première version de ce plan disait « HMAC, comme Stripe ». fal signe en ED25519, avec des clés publiques exposées en JWKS :
- en-têtes
X-Fal-Webhook-Request-Id,X-Fal-Webhook-User-Id,X-Fal-Webhook-Timestamp,X-Fal-Webhook-Signature(hex) ; - message signé : les trois premiers, puis le SHA-256 hex du corps brut, séparés par des sauts de ligne — lire le corps brut avant toute désérialisation, sinon le hash ne correspond jamais ;
- clés JWKS en cache, rafraîchies sur échec ; timestamp trop ancien refusé (rejeu) ;
- idempotence obligatoire : fal relance sur tout
4xx,5xxou timeout (15 s au premier essai) jusqu'à expiration du résultat. Clé surrequest_id; un second webhook pour un job déjà terminé répond200sans rien refaire. D'où le téléchargement des octets dans un job, pas dans la requête ; - corps :
{ request_id, gateway_request_id, status: "OK" | "ERROR", payload | error }.
3.8 Personnages — fusion des trois plans, arrêtée le 2026-09-01
Trois plans décrivaient le même objet sans se croiser. Le canon visuel (ce plan), les 3 frames de lipsync (
talking-head-plan.md) etPersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }(tts-pregenerated-plan.md). Et le code diverge déjà du plan :Instanceporte un guide au singulier (GuideName,GuidePersonaPrompt,GuideVoiceId,GuideFallbackMessages) là oùtts-pregenerated-planen prévoit deux (Viva et Marco). Un seul objet règle les trois d'un coup — et l'onglet s'appelle Personnages, pas « Avatars ».
Un modèle asymétrique : un guide adressable, N narrateurs.
Arrêté le 2026-09-02. Le plan TTS posait « le visiteur choisit entre Viva et Marco » — c'était un choix de voix déguisé en choix de guide. Le besoin réel est celui du Bastogne War Museum : trois ou quatre personnages qui narrent chacun leurs stations, avec leur voix et leur intonation. Le conservateur assigne, le visiteur ne choisit rien.
Révisé le 2026-09-02 (suite) :
Kindétait prématuré. Un guide par visite est un besoin réel — un personnage différent qui accompagne chaque parcours. Or un guide n'est rien d'autre qu'un personnage dont la facette parole est remplie. Donc pas deux types : un seul objet, et ce qu'il sait faire découle des facettes renseignées.
Facette remplie Ce que le personnage peut faire VoiceIdnarrer un contenu SystemPromptêtre le guide d'une visite ou de l'instance WakewordIdêtre appelé à la voix — au plus un par instance Un personnage peut donc narrer les étapes 1, 3, 5 et répondre aux questions du visiteur sur ce même parcours, en personnage. C'est tombé du modèle sans rien coder de plus, et c'est probablement le meilleur usage de l'assistant sur un escape game.
Persona
Id, InstanceId, Name, Role, IsArchived
-- facette VISAGE (Studio, V2)
ArchetypeKey -- pose choisie dans une grille NEUTRE pré-produite
Params jsonb -- époque, costume, âge apparent, métier
CanonVersion
-- facette VOIX (TTS)
VoiceId -- FK TtsVoice ; multilingue, une voix pour les 10 langues
VoicePrompt -- intonation / style. Aujourd'hui constante de BUILD
-- (kGeminiTtsPrompt, visitapp constants.dart:27) : c'est ce
-- champ qui fait que le gouverneur ne sonne pas comme la sentinelle
VoiceVersion
-- facette PAROLE (LLM) — remplie seulement pour qui doit pouvoir être guide
SystemPrompt? -- ex Instance.GuidePersonaPrompt
FallbackMessages? jsonb -- ex Instance.GuideFallbackMessages
WakewordId? -- nul par défaut ; requis SEULEMENT si un canal mains-libres
-- est actif. Choisi dans une LISTE, jamais saisi
WakewordStatus -- None | Requested | Available
-- Requested = modèle à entraîner + app à republier ; le
-- personnage reste utilisable, simplement pas appelable
PersonaView -- le canon est un JEU de vues, pas une image
Id, PersonaId, ResourceId, Kind (Portrait|Profil|PlanLarge|Dos), Order
TtsVoice -- catalogue en base, comme GenerationModel. OBLIGATOIRE :
Key, ProviderVoiceId, Label, -- deux constantes ne suffisent pas à quatre narrateurs
Timbre, SampleStoragePath, IsEnabled
Un seul écran, sous Guide IA › Personnages — pas sous Studio, sinon un client sans Studio ne
pourrait plus régler son assistant. Trois onglets par fiche :
| Onglet | Contenu | Ce qu'il détermine |
|---|---|---|
| Visage | archétype, Params, canon en jeu de vues, budget de références |
apparaît dans les images générées — n'existe que si studioEnabled |
| Voix | VoiceId (catalogue filtrable), VoicePrompt (intonation) |
peut narrer |
| Parole | SystemPrompt, FallbackMessages, WakewordId, « parler avec lui » |
peut être guide |
L'onglet Parole est l'interrupteur narrateur / guide. Vide → il raconte et ne répond pas. Rempli → il peut être désigné guide d'une visite ou de l'instance. Ce n'est pas un formulaire de plus, c'est ce qui définit son rôle — et c'est pour ça qu'il ne doit pas être un renvoi vers un autre écran.
Détails qui comptent dans cet onglet :
SystemPromptest écrit dans une seule langue — le modèle répond dans celle du visiteur. À ne pas confondre avec lesParamsde l'onglet Visage : ceux-là décrivent l'apparence pour le modèle image, celui-ci le comportement pour le LLM. Deux textes, deux consommateurs.FallbackMessagesreste unList<TranslationDTO>à plusieurs entrées par langue, tirées au hasard : un visiteur qui bute trois fois ne doit pas entendre trois fois la même phrase. Traduction par le même bouton IA que le reste du contenu.WakewordIdest un choix dans une liste, jamais un champ libre, et n'apparaît que si un canal mains-libres est actif.- L'aperçu vit ici : il teste le ton. Le rapport de couverture — les questions sans réponse —
reste dans
Guide IA › Général, parce que c'est une mesure, pas une conversation. - Le catalogue de voix est filtrable : genre (étiquetage éditorial, l'API ne le déclare pas —
le code le fait déjà informellement : « Sulafat (Viva, féminine) ou Umbriel (Marco, masculine) »),
timbre, et ★ recommandées écoute longue. Tout le catalogue pour les narrateurs, où la variété
est le but ; liste courte mise en avant pour le guide — une voix aiguë sur 2-3 min d'audioguide
dans un lieu réverbérant fatigue, c'est le critère qui avait fait retenir ces deux-là.
Écouter ne coûte rien : samples pré-générés une fois, globalement, à
tts-samples/{voiceId}/{lang}.mp3— hors quota client.
Ce qui reste dans Guide IA › Général — et qui n'appartient à aucun personnage :
| Bloc | Pourquoi pas dans un personnage |
|---|---|
Connaissances — index, réindexation, IncludeInAiKnowledge |
Un seul index par instance. Tous les personnages y puisent |
| Ce que demandent vos visiteurs + rapport de trous | Agrégat du lieu, ventilé par personnage — pas l'inverse |
Collecte RGPD (IsVisitorQuestionCollectionEnabled) |
Réglage juridique : le client est responsable de traitement. L'enterrer dans une fiche de personnage serait une faute |
| Activation par canal | Où l'assistant existe, pas qui il est |
| Quel personnage est le guide par défaut | Un sélecteur, pas un formulaire |
L'écran change donc de sujet : il cesse d'être la carte d'identité du guide et devient l'écran du
service. Le clic sur l'entrée parente de menu affiche Général — c'est déjà ce que fait
« Applications » aujourd'hui (initElementToShow retombe sur la première sous-entrée).
⚠️ Trois codages en dur que cet onglet doit supprimer, pas reproduire :
| Aujourd'hui | Où | Doit devenir |
|---|---|---|
| Les 4 mots de réveil, littéral Dart | voice_controller.dart:105 — ['hey_viva','hey_marco','hey_alba','hey_vasco'] |
lus depuis les personnages appelables |
| Les 2 voix, constantes | guide_ia_screen.dart:19-20 — kGuideVoiceViva / kGuideVoiceMarco |
table TtsVoice |
| L'intonation, constante de build | mymuseum-visitapp/constants.dart:27 — kGeminiTtsPrompt |
Persona.VoicePrompt |
Liens vers le contenu. Un personnage ne se rattache pas à une configuration. Il se rattache au contenu qui l'utilise — et « quels personnages apparaissent dans ce parcours » est une vue calculée, exactement comme le « Utilisée dans » de la médiathèque (§3.0bis).
| Usage | Où vit le lien | Persistant |
|---|---|---|
| Référence pour générer une image | choisi à la génération, tracé dans AiProvenance |
non |
| Narrateur d'un contenu | NarratorPersonaId sur GuidedStep / GeoPoint / SectionArticle |
oui |
| Narrateur par défaut d'un parcours | Configuration.DefaultNarratorPersonaId |
oui |
| Guide d'une visite | Configuration.GuidePersonaId + GuideKnowledgeScope |
oui |
| Guide par défaut de l'instance | Instance.GuidePersonaId — remplace les 4 colonnes Guide* |
oui |
Un narrateur peut être le guide : NarratorPersonaId accepte de pointer sur lui.
On configure dans Studio, on assigne dans l'éditeur de contenu
Un sélecteur par fiche ne suffit pas. Sur un parcours de 15 étapes, ce serait 15 menus déroulants dans 15 écrans pour dire une chose presque toujours identique — le même échec ergonomique que « prompter à la place du conservateur », transposé à l'audio. D'où la même structure que l'identité visuelle : un défaut à un niveau, une surcharge au niveau fin.
Une règle uniforme (arrêtée le 2026-09-02), plutôt qu'une liste de cas particuliers :
Le défaut se pose sur tout conteneur, la surcharge sur toute feuille.
| Conteneurs — portent un défaut | Feuilles — portent une surcharge | |
|---|---|---|
| Narrateur | Instance → Configuration → GuidedPath / SectionMap |
GuidedStep, GeoPoint, SectionArticle |
| Guide | Instance → Configuration |
— |
GuidedPath couvre l'escape game à plusieurs narrateurs — SectionParcours.GuidedPaths est une
liste, donc une configuration peut contenir plusieurs parcours. SectionMap couvre une carte de
30 POI. Configuration couvre le parcours thématique (Halloween).
Le guide s'arrête à la configuration. Il accompagne une visite entière ; changer d'interlocuteur en passant de la carte à l'article serait déroutant. C'est le narrateur qui change à chaque contenu, pas le guide.
GUIDE Configuration.GuidePersonaId
?? Instance.GuidePersonaId
NARRATEUR <feuille>.NarratorPersonaId -- la surcharge, si posée
?? <conteneur direct>.DefaultNarratorPersonaId -- GuidedPath ou SectionMap
?? Configuration.DefaultNarratorPersonaId
?? le guide résolu ci-dessus -- le guide raconte par défaut
La seule vraie limite est le mains-libres. Un wakeword est le nom qu'on prononce, et les modèles OpenWakeWord sont embarqués dans le build de l'app. Deux guides adressables dans un même lieu, ce serait deux modèles à l'écoute et un visiteur qui doit retenir un nom différent par parcours. Donc : sur lunettes et casque VR, c'est le guide de l'instance qui répond. Le guide par parcours vaut sur mobile, web et kiosk, où l'on appuie sur un bouton au lieu d'appeler un nom. Un wakeword propre à un parcours reste possible, mais c'est un modèle à entraîner par personnage — l'add-on facturable se multiplie.
Cadrage des connaissances — décisif sur un escape game. Configuration.GuideKnowledgeScope
= Instance (défaut) | Configuration. Aujourd'hui ContentEmbedding.ConfigurationId est
documenté comme servant « à privilégier la visite en cours au classement — jamais à filtrer »,
et IVectorStoreService.SearchAsync reçoit déjà currentConfigurationId. La plomberie existe ; il
manque le mode strict. Sans lui, le guide d'une énigme peut révéler une solution écrite dans un
autre parcours.
L'assignation se fait dans une vue de masse — l'onglet « Narrateurs » du parcours, une ligne par étape — pas fiche par fiche. Quatre exigences sur cet écran :
- Hérité et surchargé se distinguent à l'œil :
↳ Le gouverneuren gris (vient du parcours) vs La sentinelle en gras (choisi ici). Sans ça, on ne sait pas ce qui bougera si on change le défaut. - « Forcer sur toutes les étapes » est un bouton séparé du sélecteur de défaut : poser un repli et effacer des décisions ne sont pas le même geste. Confirmation avant.
- Le coût de régénération s'affiche par ligne (
⟳ 4 à régénérer). Changer un narrateur invalide son audio dans toutes les langues : basculer le défaut d'un parcours de 8 étapes en 4 langues, c'est 32 fichiers. Le conservateur doit le voir avant, pas le découvrir après. - Rien n'est régénéré avant validation : une barre annonce le total et la durée estimée.
C'est l'avertissement déjà prévu par
tts-pregenerated-plan, mais chiffré au lieu d'être générique.
Pourquoi l'héritage est acceptable ici alors qu'on l'a refusé pour l'identité visuelle (§3.2). L'identité est un composite : une palette vide veut-elle dire « hérite » ou « aucune contrainte » ? Indécidable. Un narrateur est une référence unique, et la valeur résolue est affichée sur chaque ligne. La règle générale : on hérite un champ, jamais un formulaire.
Ce qu'on n'assigne pas. Un personnage ne se rattache pas à une configuration au sens de
l'appartenance. Le contexte de création sert à deux choses réelles — choisir l'identité visuelle à
appliquer (sans quoi un personnage d'escape game sortirait dans le style de base de l'instance) et
trier le sélecteur — mais jamais à créer une exclusivité : ce serait deux copies du même personnage,
donc deux canons qui divergent, exactement ce que Persona existe pour empêcher. Et « apparaît
dans l'escape game » reste calculé, jamais déclaré : un champ déclaré peut mentir, un compte
d'usages non.
Le casting — décidé le 2026-09-15 (décision 25)
Un seul cas justifiait un vrai lien configuration ↔ personnage : présenter le casting au visiteur (« les 4 témoins que vous allez rencontrer »), qui demande un ordre et un sous-ensemble éditorial qu'un ensemble calculé ne porte pas. Il est retenu — c'est aussi le premier usage visiteur réel du canon visuel, qui ne servait jusqu'ici qu'à la cohérence des images générées.
La règle qui rend l'écran simple : on assigne un personnage à un seul endroit — le contenu. Le casting n'est pas une seconde saisie, c'est une vue de ce qui est déjà calculé, plus deux champs éditoriaux : l'ordre et la mise en avant. Deux endroits où déclarer la même chose, ce sont deux vérités qui divergent — le même piège que les deux canons évités plus haut.
Écran Configuration › Personnages. Une ligne par personnage réellement utilisé : portrait canon,
nom, rôle, d'où vient l'usage (« 6 étapes du parcours principal », cliquable). Deux contrôles,
pas trois : une case « montré au visiteur » et une poignée de réordonnancement. Aucun sélecteur de
personnage sur cet écran : pour en ajouter un, on lui assigne un contenu ; pour en retirer un, on
change le narrateur de ses contenus — et le lien pour y aller est sur la ligne.
Les trois états — c'est là que se joue la clarté, parce que la majorité des clients n'auront jamais qu'un seul personnage et ne doivent pas voir un écran de casting vide :
| Situation | Ce que montre l'écran | Ce que le client a à faire |
|---|---|---|
| Aucun narrateur assigné | Bandeau : « Léon raconte toute cette visite » + (guide de l'instance) + lien vers sa fiche | rien — cas nominal |
| Un seul personnage | Le même bandeau, pas de liste | rien ; l'interrupteur casting est masqué, pas grisé |
| Deux personnages ou plus | La liste + l'interrupteur « Présenter les personnages au visiteur » (IsCastingShownToVisitor, défaut off) |
cocher, ordonner |
Quatre règles, dans la ligne des quatre exigences de l'onglet « Narrateurs » :
- Hérité et choisi se distinguent à l'œil, ici aussi :
↳ Le gouverneuren gris quand il n'est là que comme repli du parcours, La sentinelle en gras quand un contenu le désigne. - Rien n'apparaît côté visiteur sans un geste explicite. Un client qui assigne un deuxième narrateur ne doit pas découvrir un nouvel écran dans son app sans l'avoir demandé.
- Un personnage sans portrait canon s'affiche avec un gabarit neutre et un avertissement, et ne peut pas être « mis en avant » : un casting à trous est pire que pas de casting.
- L'ordre par défaut est celui d'apparition dans la visite, pas l'ordre de création : c'est ce que le visiteur va vivre, et ça évite au client d'avoir à réordonner quoi que ce soit dans 90 % des cas.
Table ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured } — Order et
IsFeatured seulement. L'appartenance reste calculée : un champ déclaré peut mentir, un compte
d'usages non.
Règle du wakeword — c'est une exigence du canal, pas une propriété du personnage.
| Canal | Sollicitation | Wakeword |
|---|---|---|
| Lunettes Ray-Ban, casque VR | mains libres, micro ouvert | requis |
| Mobile, web, kiosk | bouton push-to-talk | aucun |
Donc : le nom est libre par défaut. Il ne devient contraint que si l'instance active un canal mains-libres — là il doit correspondre à un modèle OpenWakeWord disponible.
Ce que le code dit vraiment (vérifié le 2026-09-02, ma première version de ce paragraphe était
fausse) : NativeWakeWordEngine fait tourner N classifieurs en parallèle et l'événement dit
lequel a déclenché (detected:hey_marco). Cinq modèles sont embarqués — hey_viva,
hey_marco, hey_alba, hey_vasco, hey_visit — et quatre tournent déjà, en dur
(voice_controller.dart:105), sans aucun lien avec le CMS. Plusieurs personnages appelables est donc
déjà possible techniquement ; c'est le lien modèle → personnage qui manque, pas la capacité.
Le plafond réel est le CPU du téléphone (un classifieur TFLite par modèle), pas 1.
Comment le refus du nom incohérent fonctionne en pratique. Il n'y a pas de champ libre :
rendre un personnage appelable, c'est choisir un modèle déjà entraîné, et le nom affiché devient
celui-là. Nom affiché et nom prononcé sont le même champ — donc l'écart n'est pas un état qu'on
peut créer, seulement un état qu'on peut hériter d'avant cette règle (GuideName est du texte
libre aujourd'hui). Côté serveur : WakewordId ne peut être posé que si Name correspond au nom du
modèle, 400 sinon.
Et si le client veut garder « Léon » : WakewordStatus passe à Requested, tu entraînes
hey_leon, il faut l'embarquer et republier l'app. Le personnage reste utilisable entre-temps,
simplement pas appelable — et l'écran le dit, avec le délai, plutôt que de laisser croire que c'est
immédiat.
🐛 Bug relevé au passage — le chemin mains-libres Android est cassé aujourd'hui. Le moteur passe
le nom du modèle dans onDetectedWithCommand, que VoiceOrchestrator._onWakeWordWithCommand
(voice_orchestrator.dart:132) interprète comme la question du visiteur et envoie à
_dispatch(). Le visiteur dit « Hey Marco », l'assistant répond à la question « hey_marco » et
n'ouvre jamais le cycle d'écoute. À corriger indépendamment du Studio — carte kanban posée.
Ce que la fusion débloque
- Un personnage qui narre. Le TTS pré-généré stocke l'audio en
List<TranslationDTO>d'idsResourcepar section. Si la narration d'une étape est attribuée au gouverneur, elle sort avec sa voix et son intonation, pas celles du guide. Le pipeline existe déjà au plan. - Le stockage TTS est divisé par deux, pas multiplié.
tts-pregenerated-planprévoit « le stockage multiplié par le nombre de personas actifs (max ×2) » — inévitable si le visiteur choisit, puisque chaque contenu doit alors exister dans les deux voix. Avec un narrateur assigné par contenu, chaque contenu n'existe qu'une fois : quatre narrateurs coûtent moins que deux personas au choix. - Le talking head disparaît (décision 9). Son lipsync dépendait d'un moteur abandonné ; le portrait canon donne déjà un visage à afficher à côté du lecteur audio.
- Un seul mot « personnage » dans le back-office, au lieu de trois notions homonymes dans trois écrans.
- La divergence code/plan sur le nombre de guides se résout : des lignes
Personaremplacent les colonnes à plat, la migration déplace les valeurs existantes dans une lignePersonaà facette parole remplie, désignée parInstance.GuidePersonaId.
Ce qu'il ne faut surtout pas fusionner
- Le wakeword. Il reste une exigence du canal :
WakewordIdn'est proposé que si un canal mains-libres est actif, jamais saisi librement — et un nom custom reste un add-on facturable (entraînement OpenWakeWord). SystemPromptetParams. « Tu parles en belge familier » est une instruction au LLM ; « uniforme d'officier, 55 ans » est une description pour le modèle image. Deux textes, deux consommateurs. Les réunir dans un champ « personnalité » serait une régression.- La configuration du guide. Elle reste dans Guide IA, indissociable des connaissances, des questions des visiteurs, des messages de repli et de l'aperçu de conversation. Les deux écrans partagent l'objet, pas le formulaire — chacun édite les facettes qu'il possède, et renvoie vers l'autre. Le guide est non archivable depuis le registre.
Deux pièges de la fusion
- Versionner les facettes séparément —
CanonVersionetVoiceVersion. Sans ça, éditer un costume régénérerait 12 fichiers audio, et changer une voix invaliderait 23 images.tts-pregenerated-planprévoit déjà l'avertissement « modifier la voix ou le persona va re-générer tous les fichiers audio » : il doit devenir par facette. - Le catalogue de voix doit devenir une table. Deux constantes suffisaient à un guide ; trois
personnages qui parlent se les partageraient. Même traitement que
GenerationModel: en base, éditable SuperAdmin, avec le sample déjà prévu àtts-samples/{voiceId}/{lang}.mp3.
Le sous-onglet contient un registre de personnages, pas une galerie d'images : une ligne par personnage (canon, rôle, nombre de vues, nombre d'images produites, configurations où il apparaît), et une fiche qui ouvre le canon, les paramètres, le budget de références et les usages.
Parcours de création : grille d'archétypes → 4 paramètres → 4 variantes → validation → portrait canon. Ensuite, toute génération concernant ce personnage passe les vues du canon en référence n°1, avant les références de l'identité visuelle.
Cinq points tranchés en concevant l'écran :
- La grille d'archétypes n'est pas une grille de portraits finis. Le style est verrouillé par l'identité visuelle : une silhouette en gravure ancienne serait inutilisable chez un client en aquarelle, et pré-produire N archétypes × M styles est un piège combinatoire. Ce sont des poses neutres au trait — cadrage, posture, carrure. Une douzaine, produites une fois, valables pour tous les styles.
- Un canon à une seule image ne tient pas 15 étapes. Un buste 3/4 ne donne pas assez au modèle
pour un plan large ou un dos. D'où
PersonaView: un portrait obligatoire + jusqu'à 3 vues validées, envoyées ensemble. Les vues coûtent des crédits ; c'est ce qui rend la récurrence réelle. - Le budget de références se télescope. Identité 1-5 + canon 1-4 + image source éventuelle
(avant/après) = jusqu'à 10, le plafond de FLUX.2. Ordre de priorité imposé côté serveur :
vues du canon d'abord, références d'identité ensuite, tronqué au plafond du modèle courant
(
GenerationModel.MaxReferenceImages). L'UI affiche la jauge « 8 / 10 » — sans elle, on découvre la limite par un échec fal.ai en production. - Régénérer un canon casse la continuité. Les images déjà produites restent (ce sont des
Resource), mais le visage change. On avertit avec le compte exact, on ne bloque pas, on n'efface rien — même logique queVisualIdentity.Version. Et on archive un personnage, on ne le supprime pas :AiProvenancepointe dessus. - Quelle identité pour générer un avatar de portée instance ? L'identité de base par défaut, surchargeable au moment de la génération si le personnage naît dans un parcours thématique.
⚠️ Le talking head est abandonné — sa dépendance était déjà cassée.
v2/talking-head-plan.md:11 anime ses frames « selon les timestamps retournés par Google Cloud
TTS », via enable_time_pointing — un paramètre qui n'existe que dans l'API Cloud TTS. Or le
code livré tourne sur Gemini TTS (GeminiTtsEngine, gemini-2.5-flash-preview-tts, voix
Sulafat / Umbriel) : il n'y a aucune source de timestamps. Le lipsync 3 frames n'est donc pas
« à faire », il est irréalisable en l'état.
tts-pregenerated-plan.mdporte la même divergence : son en-tête et sa ligne 134 disent Google Cloud TTS, sa section dual-persona dit « les voix Gemini TTS retenues sont multilingues ». Deux moteurs dans un même document. Corrigé par un en-tête sur ce fichier.
Remplacement : le portrait canon statique à côté du lecteur audio — 80 % de la valeur perçue pour
zéro travail, puisque le canon existe déjà. Et si du mouvement est voulu plus tard, une boucle
vidéo de 6 s (lot 10, Kling/Veo) est un meilleur produit qu'un lipsync 3 frames en 2026.
talking-head-plan.md se réduit à un renvoi vers cette section.
Un paragraphe antérieur faisait encore du talking head un « consommateur » de
Persona(AvatarId, frames 1-2 générées par le Studio). Retiré le 13/09 : il contredisait la décision 9.
3.9 Avant / après
Pas de reconstruction 3D de ville — hors de portée des modèles image-to-3D actuels, qui gèrent des objets isolés et compacts.
À la place : gabarit before-after qui prend la photo actuelle comme image source, génère l'état
« avant » calé sur le même angle, et produit une paire :
BeforeAfterPair { Id, InstanceId, AfterResourceId, BeforeResourceId,
Label jsonb, VideoResourceId? }
Côté visiteur, un composant slider avant/après — à écrire trois fois (Flutter ×2 + React). C'est un lot à part entière, pas un effet de bord du Studio. La transition vidéo de quelques secondes est optionnelle et arrive avec le lot vidéo.
3.10 Permissions
Security.RolePermissions est un Dictionary en dur à 4 entrées. On ajoute une permission
détachée du rôle :
Permissions.AssetValidation = "Manager.assetvalidation"
Policies.AssetValidation = "Manager.AssetValidation"
- Accordée d'office à
SuperAdminetInstanceAdmin. - Accordable individuellement à un
ContentEditor(nouveaubool CanValidateAssetssurUser). - Sur une instance à un seul utilisateur — le cas courant — l'
InstanceAdminl'a, donc rien ne bloque.
ContentEditor peut générer ; seul un porteur de la permission peut valider.
3.11 Traçabilité, mention IA, droits
Sur Resource, un bloc de provenance :
Resource += AiProvenance jsonb?
{ generatedBy: "studio", modelKey, providerModelId, providerRequestId,
templateKey, effectivePrompt, negativePrompt, referenceResourceIds[],
visualIdentityId, visualIdentityVersion, seed,
generatedAt, generatedByUserId, validatedByUserId, validatedAt,
rightsHolder }
rightsHolderpar défaut = nom de l'instance → les droits sur le contenu généré sont au client, écrit dans la donnée, pas seulement dans les CGU.Resource.Origin == Generateddéclenche le badge « image générée par IA » côté visiteur — une colonne, pas un test sur un jsonb (décision 20).AiProvenanceporte le détail, etResource.SourceResourceIdpointe l'image source quand il y en a une.- Le libellé du badge est traduit dans les langues déclarées par la configuration
(
Configuration.Languages), pas dans les 10 langues supportées. - Descend par
ResourceDTO→ExportConfigurationDTO→ les 3 clients visiteurs. C'est le champ qu'on oubliera dans un des trois : à cocher explicitement dans le lot.
Pas de watermark gravé par défaut. Trois raisons : c'est irréversible (l'asset devient inutilisable si la règle change), c'est laid sur une illustration de POI dans un musée, et l'AI Act n'exige pas un marquage visible sur l'image — il exige que le visiteur soit informé. Le badge UI + la provenance en base + les métadonnées fichier remplissent l'obligation sans abîmer l'asset.
Gravure disponible en option : Instance.IsAiWatermarkBurned, désactivée par défaut. Réutilise le
chemin d'IsImageWatermark, qui doit de toute façon être réécrit en ImageSharp.
4. Contrat d'API
Base /api/Studio, [Authorize(Policy = Policies.ContentEditor)] sauf mention.
Identité visuelle
GET /api/Studio/identities?instanceId=&configurationId=
GET /api/Studio/identities/{id}
POST /api/Studio/identities → VisualIdentityDTO
PUT /api/Studio/identities/{id} → incrémente Version
DELETE /api/Studio/identities/{id} → refusé si c'est l'identité de base
POST /api/Studio/identities/{id}/duplicate body { configurationId }
GET /api/Studio/identities/resolve?configurationId=
POST /api/Studio/identities/{id}/preview → 3 images d'essai, coût réel, hors cible
GET /api/Studio/styles → catalogue des styles
Génération
GET /api/Studio/templates?kind=&appliesTo=
POST /api/Studio/estimate { templateKey, modelKey?, variantCount }
→ { credits, balanceAfter, blockedBy? }
POST /api/Studio/generate { instanceId, configurationId?, templateKey,
fields{}, variantCount, modelKey?,
personaIds[]?, -- « qui apparaît » : 1 recommandé, 2 max
sourceResourceId?, sourceFidelity?,
-- none | subject | subjectPose | framing
advancedPrompt?,
target{ kind, id, field }? }
→ 202 { jobId, estimatedCredits,
references{ canon, identity, source, truncated } }
GET /api/Studio/jobs/{id} → { status, assets[], error?, creditsCharged }
GET /api/Studio/jobs?configurationId=&status=&page=
POST /api/Studio/jobs/{id}/cancel
Assets
GET /api/Studio/assets/{id}/content -- proxy authentifié, brouillons uniquement
POST /api/Studio/assets/{id}/validate [Policy = AssetValidation]
→ { resourceId } | 413 quota stockage
POST /api/Studio/assets/{id}/reject
POST /api/Studio/assets/discard { assetIds[] } -- fermeture du panneau
POST /api/Studio/assets/{id}/regenerate -- même job, nouveau seed
Personnages
GET /api/Studio/personas?instanceId=&includeArchived=
GET /api/Studio/personas/{id} → canon, vues, params, usages
GET /api/Studio/personas/archetypes → poses neutres, catalogue serveur
POST /api/Studio/personas { name, role, archetypeKey, params{},
identityId? } → job
POST /api/Studio/personas/{id}/canon { assetId } → fige le portrait, CanonVersion++
POST /api/Studio/personas/{id}/views { kind } → job (profil, plan large, dos)
DELETE /api/Studio/personas/{id}/views/{viewId}
POST /api/Studio/personas/{id}/archive
GET /api/Studio/configurations/{id}/casting → liste calculée + Order/IsFeatured
PUT /api/Studio/configurations/{id}/casting { isShownToVisitor, entries[{personaId, order, isFeatured}] }
Crédits
GET /api/Studio/credits?instanceId= → { balance, reserved, available,
expiresAt, studioEnabled,
perUserDailyCap, usedToday }
GET /api/Studio/credits/ledger?from=&to=&format=csv
POST /api/Studio/credits/grant [Policy = SuperAdmin]
{ instanceId, amount, note }
→ ligne Kind: Grant, expiresAt repoussé
Le premier alimente la jauge et son popover, le second l'écran Abonnement. Pas de sous-onglet Studio.
Médiathèque — usages
✅ Livré en V1. Contrat réel : ../v1-mediatheque-plan.md §2.2-2.3.
Ingestion — lot 0 (décision 14)
POST /api/Resource/upload-url { instanceId, type, fileName, contentType, sizeBytes, resourceId? }
→ { resourceId, uploadUrl, requiredHeaders, expiresAt }
| 413 (quota ou plafond du type, sur la taille annoncée)
| 503 (bucket non configuré)
-- resourceId absent : création, id réservé, aucune ligne encore
-- resourceId présent : remplacement d'un fichier existant
PUT {uploadUrl} le navigateur, directement chez Google — pas l'API
POST /api/Resource/ingest { resourceId, instanceId, type, fileName, label? }
→ ResourceDTO | 413 (taille réelle) | 404 (rien dans incoming/)
-- création : la ligne naît ici
-- remplacement : même id, DateUpdate avancé, la visite hors
-- ligne re-télécharge
Remplacent, côté manager-app, le trio POST /api/Resource + putData Firebase + PUT /api/Resource.
POST /api/Resource reste pour les types URL (ImageUrl, VideoUrl, JSONUrl), qui n'ont pas de
fichier. L'ancienne route POST /api/Resource/upload (base64, System.Drawing) est supprimée.
ResourceDTO expose en plus origin et sourceResourceId.
Webhook
POST /api/StudioWebhook/fal [AllowAnonymous] + signature ED25519 (§3.7)
Administration
GET/PUT /api/Studio/admin/models [Policy = SuperAdmin]
GET/PUT /api/Studio/admin/templates [Policy = SuperAdmin]
5. Schéma de données
Migrations additives, une par lot (le §8 dit laquelle), aucune donnée existante touchée — sauf
GuidedStep (lot 4) et Instance.Guide* (lot 7).
Tables neuves
| Table | Clés et index |
|---|---|
VisualIdentities |
InstanceId FK, ConfigurationId FK nullable, index unique (InstanceId, ConfigurationId) |
GenerationTemplates |
Key unique |
GenerationModels |
Key unique |
GenerationJobs |
InstanceId, ConfigurationId?, UserId, VisualIdentityId, index (InstanceId, Status) |
GeneratedAssets |
GenerationJobId FK cascade, ResourceId? FK |
Personas |
InstanceId, index (InstanceId, IsArchived) — pas de Kind (décision 7) ; remplace les colonnes Guide* de Instance |
PersonaViews |
PersonaId FK cascade, ResourceId FK |
TtsVoices |
Key unique — catalogue, plus deux constantes |
ConfigurationPersonas |
ConfigurationId FK, PersonaId FK, index unique (ConfigurationId, PersonaId) — ordre et mise en avant seulement, l'appartenance reste calculée (décision 25, lot 8bis) |
BeforeAfterPairs |
AfterResourceId, BeforeResourceId |
CreditLedgerEntries |
InstanceId, UserId, GenerationJobId?, index (InstanceId, CreatedAt) |
Colonnes ajoutées
Resource += Origin int not null default 0 -- Uploaded=0 | Generated=1 | Derived=2 (lot 0)
SourceResourceId? -- lignage parent (lot 0)
AiProvenance jsonb? -- détail (lot 3)
Configuration += IsCastingShownToVisitor bool not null default false -- lot 8bis
Instance += StudioEnabled, StudioCreditsBalance, StudioCreditsExpireAt,
StudioPerUserDailyCap, -- lot 1, livré
IsAiWatermarkBurned -- lot 6
User += CanValidateAssets
VisitorQuestion += PersonaId? -- sans lui, les stats restent agrégées sur un
-- assistant imaginaire : impossible de voir qu'un
-- personnage répond mal
ResourceType -- rien : Image360 = 11, Video360 = 12, Model3D = 13 existent depuis le 2026-09-12
Corrections d'existant que le module rend nécessaires
IResourceBlobService: ajouterCreateUploadUrl(URL V4 signée enPUT),UploadAsync,ReadAllAsync,CopyAsync(copie côté Google : les octets ne passent pas par le VPS),GetInfoAsync. Le credential de compte de service est déjà chargé pour FCM et sait signer ; seulFirebase:StorageBucketest vide en config. ⚠️ Un objet écrit par le SDK GCS n'a pas d'URL Firebase. Poser la métadonnéefirebaseStorageDownloadTokens(un GUID) à l'écriture, et construirehttps://firebasestorage.googleapis.com/v0/b/{bucket}/o/{chemin encodé}?alt=media&token={guid}— sinonResource.Urlreste vide et les trois clients visiteurs n'affichent rien.ResourceIngestionService: seul point d'entrée qui écrit le fichier d'uneResource, quelle que soit sa provenance (décision 14). Détail au §8, lot 0.GuidedStep.ImageUrl→ImageResourceId, et l'inclure dansGetReferencedResourceIds. Sans ça les images générées de l'escape game ne partent pas offline. Migration de données.ImageHelper→ ImageSharp. Le code actuel ne tourne pas sur Linux.- Ajouter
LBàSupportedLanguages(appsettings.json) etconstants.dart:165, + drapeau. Indépendant du Studio, mais bloquant pour le projet luxembourgeois en cours.
6. Plan d'implémentation séquencé
Deux versions, tranchées le 2026-09-02.
| Contenu | Engage une clé API payante | |
|---|---|---|
| V1 | Refonte de la Médiathèque : facettes, usages, panneau de détail, bugs corrigés | non |
| V2 | Studio : identité visuelle, génération, crédits, personnages, vidéo, 3D | oui |
MVP de la V2 = escape game, images seulement. Faible enjeu scientifique, fort besoin de cohérence, gros volume d'images.
═══ V1 ═══
Sortie de ce document le 2026-09-02. La refonte de la Médiathèque a sa propre spec, autonome et exécutable sans rien lire d'ici :
Elle contient l'état des lieux mesuré, les 2 endpoints d'index inverse avec leurs 3 pièges, les fichiers à toucher, le piège du sélecteur de ressource embarqué, l'ordre d'exécution et la checklist de test. §3.0bis ci-dessus reste la trace de la décision de conception.
Ce qu'elle laisse à la V2, sans rien fermer : l'onglet « Générer », la facette « Générées par IA », le badge et le bloc de provenance.
═══ V2 ═══
Lot 0 — Socle de stockage et ingestion serveur (prérequis dur)
IResourceBlobService en écriture et en URL signée, ResourceIngestionService unique, bascule des deux
uploads de manager-app sur URL signée + ingestion (décision 14), fermeture des écritures clientes
dans les règles Storage, plafonds par type (décision 18),
Resource.Origin / SourceResourceId (décision 20), ImageHelper réécrit en ImageSharp, suppression
de la route upload morte, Firebase:StorageBucket renseigné, ajout de LB. Détail et
vérifications : §8.
Rien du Studio ne fonctionne sans ce lot. Bénéficie aussi au TTS pré-généré
(tts-pregenerated-plan.md), qui attend exactement la même chose.
Lot 1 — Crédits
CreditLedger, colonnes Instance/SubscriptionPlan, réserve/charge/remboursement, GET /credits,
POST /credits/grant (SuperAdmin), export CSV. Crédits rechargeables, un solde et une date repoussée
à chaque Grant, pas de reset mensuel (§3.5, décisions 16-17) : le balayage de péremption est un job
Hangfire, précédents en place (AuditLogPurgeService). Aucun prix, aucune recharge Stripe. Troisième jauge dans le pied de menu + popover au clic ; détail et journal dans
Abonnement. Corriger au passage le gating de l'entrée Abonnement (main_screen.dart:712-714),
aujourd'hui réservée à plan-essentiel. Testable seul, sans aucun appel fal.ai.
Lot 2 — Identité visuelle (le plus structurant)
Entité, résolution instance/configuration, duplication, PromptPreamble calculé, écran manager-app.
Encore aucune génération : l'écran se valide sur sa seule ergonomie.
Lot 3 — Couche fournisseur + un modèle
IGenerationProvider au contrat multi-fichier (décision 21), FalAiGenerationProvider, catalogue
GenerationModels en base, POST /generate, job Hangfire, webhook signé ED25519, polling de secours,
validation des brouillons via ResourceIngestionService. Un seul gabarit pour commencer : puzzle-decor.
Se termine par le calibrage du catalogue v0 (§9.4) et la fixation des prix (décision 16) — le
lot 4 ne s'ouvre pas avant.
Lot 4 — Génération dans le sélecteur de ressource
Onglet « Générer » dans ResourceTab / showNewResource, et affordance dans ResourcesScreen.
Un seul point d'accroche : il apparaît du coup dans les 13 types de section, les POI, les étapes
et l'onglet Ressources, sans toucher un seul éditeur (voir §3.0). Panneau : gabarit, 2-3 champs,
coût estimé, 4 variantes, validation, affectation au champ quand un target est connu.
Correction GuidedStep.ImageUrl → ImageResourceId ici, sinon rien ne part offline.
Gabarits puzzle-decor, historical-object, scene-evocation. Points de départ : « depuis le
texte » et « depuis une image » (none | subject), objets et lieux seulement (décision 15).
Lot 5 — Greffe du Studio sur la Médiathèque
La refonte elle-même est en V1 — codée le 2026-09-02 (../v1-mediatheque-plan.md), checklist navigateur encore à passer. Ne reste ici que ce que le Studio y ajoute : la facette Origine › Générées par IA, le badge sur les vignettes, et le bloc provenance dans le panneau de détail.
Plus, dans Studio, un seul écran neuf : À valider, pour les brouillons — qui ne sont pas encore
des Resource et n'ont donc rien à faire dans la bibliothèque. En usage normal cette file est vide.
Lot 6 — Provenance et mention IA bout en bout
AiProvenance, descente jusqu'aux 3 clients visiteurs, badge traduit, rightsHolder, option de gravure.
Fait le 2026-09-15. À la validation,
AiImageMarkerécrit un XMP IPTCDigitalSourceType = trainedAlgorithmicMedia
dc:rights=rightsHolder(survit au ré-encodage de l'ingestion, testé). Gravure :Instance.IsAiWatermarkBurned(interrupteur SuperAdmin dans le dialogue d'instance), texte fixe « Généré par IA · AI-generated » en bas à droite, police DejaVu Sans embarquée. ⚠️IsImageWatermark(filigrane du lieu) reste mort — carte 305, rien partagé.
Lot 7 — Personnages (fusion guide + figures)
Registre de personnages, grille d'archétypes neutres (une douzaine à pré-produire), paramètres, canon en jeu de vues, jauge de budget de références, réinjection en référence n°1, archivage.
État au 2026-09-15. Fait :
Persona, écran Personnages,TtsVoiceen base, migrationAddPersonas. Fichiers pré-produits le 15/09 et embarqués dans manager-app (assets/images/studio/) plutôt que déposés au bucket : une URL de bucket différerait entre dev et prod, alors qu'un asset marche partout —VisualStyle.SampleImageUrlprime s'il est rempli. 8 échantillons de style (un même moulin dans les 8 styles) et 12 figures d'archétypes (mannequin neutre au trait), ≈ 0,60 $. Reste : l'écoute des voix — 6 extraits audioTtsVoiceà générer en Gemini TTS, bloqué sur la clé (absente en local).
Migration structurante : les 4 colonnes Instance.Guide* deviennent une ligne
Persona à facette parole remplie (pas de Kind, décision 7), et Instance porte un
GuidePersonaId. Le catalogue TtsVoice passe
en base. talking-head-plan.md est archivé (décision 9).
Lot 8 — Narration attribuée (avec le lot TTS)
Le lot « Bastogne » : plusieurs personnages qui racontent chacun leurs stations.
NarratorPersonaIdsurGuidedStep/GeoPoint/SectionArticle+Configuration.DefaultNarratorPersonaId, avec la chaîne de résolution de §3.8.Persona.VoicePrompt(l'intonation, aujourd'hui constante de build) et la tableTtsVoice.- Onglet « Narrateurs » du parcours : assignation de masse, hérité/surchargé distingués, coût de régénération par ligne, rien de régénéré avant validation.
- Côté visiteur : portrait canon statique à côté du lecteur audio.
Découpage du 2026-09-15. 8a — fait :
NarratorPersonaId(étape, point, article),DefaultNarratorPersonaId(parcours, carte, configuration),Configuration.GuidePersonaId,NarratorResolver(chaîne de §3.8, source renvoyée pour distinguer hérité / choisi, personnage archivé sauté), migrationAddNarrators, DTO serveur et Dart. 8b — fait (décisions de Thomas du 15/09 : bouton explicite, crédits Studio, MP3 via ffmpeg) :NarrationService+api/Narration/{kind}/{id}(état par langue : absent / à jour / à régénérer / déposé à la main) et/generate(réserve, job Hangfire,ResourceaudioGenerated, ancienne narration supprimée, fichier déposé gardé). Gemini 2.5 Flash TTS ≈ 0,015 $/min = 1 crédit par minute entamée, par langue. Empreinte texte + personnage + version de voix dansAiProvenance.GeoPoint.AudioIdsajouté. ⚠️ Jamais exécuté contre Gemini ni ffmpeg réels : pas de clé ni de ffmpeg en local. 8c — fait côté parcours : onglet « Narrateurs » dans l'éditeur de parcours (défaut du parcours, surcharge par étape en gris hérité / gras choisi, « toutes les étapes suivent le défaut » confirmé, état et coût par ligne, total confirmé avant génération),NarrationApiécrit à la main. Reste : même assignation sur les points de carte et l'article, guide et narrateur par défaut dans l'écran Configuration. ⚠️ Écran jamais affiché : analysé, pas lancé. 8d — portrait canon côté visiteur, 3 fronts.
Lot 8bis — Casting visiteur (petit, juste après le lot 8)
N'a de sens qu'une fois plusieurs narrateurs assignables, donc après le lot 8 — et il est court.
ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured }etConfiguration.IsCastingShownToVisitor(défautfalse).- Écran
Configuration › Personnages: liste calculée depuis les narrateurs assignés, trois états (0 / 1 / n personnages), aucun sélecteur de personnage — voir §3.8. - Côté visiteur : écran d'entrée « les personnes que vous allez rencontrer », portrait canon + nom + rôle. Trois fronts. Ne s'affiche que si l'interrupteur est armé et si ≥ 2 personnages ont un portrait.
Lot 9 — Avant / après
Gabarit image-to-image + composant slider ×3 clients.
Lot 10 — Vidéo
Kling 3.0 / Veo 3.1, clips 6-8 s en boucle, canon d'un personnage comme image source. Impact stockage sérieux : à cadrer côté quota avant d'ouvrir. C'est ici que revient l'idée d'un portrait animé, sous une forme qui tient en 2026.
Non engagé — arbitré le 2026-09-15. Faire parler un personnage reste souhaitable, mais le blocage est le stockage, pas la techno (le lipsync, lui, est mort pour une autre raison : décision 9). Tant que l'enveloppe de stockage n'est pas chiffrée, ce lot reste après tout le reste — V2 tardive, voire V3. Le portrait canon statique du lot 8 est la réponse en attendant.
Lot 11 — 3D
Objets isolés, export GLB, ResourceType.Model3D (déjà dans l'enum, valeur 13). Suppose un viewer
GLB côté visiteur — dépend du chantier VR/XR (vr-quest-unity-plan.md).
Deux modèles au catalogue, pas un (décision 24) : 3d-default mono-image et 3d-multiview
(meshy/v7/multi-image-to-3d). Tous deux sur fal, donc zéro intégration supplémentaire.
Cas d'usage nommé — « l'objet de la vitrine, en 3D »
C'est le cœur du lot, et il ne se résume pas à un appel d'API : la qualité du résultat dépend davantage des photos fournies que du prompt. C'est le seul endroit du Studio où c'est vrai, et l'écran doit être construit autour de ça.
-
Le conservateur ouvre le sélecteur de ressource d'une
SectionModel3D, onglet Générer, gabarit « Illustration 3D d'un objet » — libellé volontairement distinct de « objet numérisé » (photogrammétrie, prestation hors Studio) : les vendre sous le même mot est un risque réputationnel chez une institution scientifique. -
Quatre emplacements de dépôt nommés et illustrés — pas une zone « glissez vos images ici » :
Face Profil Dos 3/4 obligatoire recommandé recommandé recommandé Chaque case porte une silhouette d'exemple. Sous le bloc, une seule consigne, courte : même objet, même lumière, fond neutre, objet entier dans le cadre. C'est la recommandation du modèle, et elle pèse plus lourd que n'importe quel réglage.
-
Le coût se met à jour en direct, et le changement de modèle est annoncé : 1 photo → modèle mono-image ; dès la 2ᵉ → Meshy v7 multi, ~25× plus cher. Une ligne explicite au moment où la bascule se produit, jamais une facture découverte après coup (même principe que le coût de régénération par ligne de l'onglet Narrateurs).
-
À l'inverse, ne pas culpabiliser le mono-image : une seule photo reste un usage légitime et bon marché pour une illustration. La formulation est « plus de vues = plus fidèle », pas « vous avez mal fait ».
-
Génération asynchrone en job composite (parent + enfants, décision 21), puis normalisation canonique du GLB à l'ingestion — échelle, pivot, orientation (décision n°2 d'
immersif-frontiere-plan§5) : sans elle les POI posés sur un modèle sautent à la régénération suivante. -
Provenance visible obligatoire et non désactivable sur le
Model3Dgénéré (décision n°11 du même plan) — contrairement aux images, où la gravure est optionnelle. Un visiteur qui fait tourner « le casque de Léon » croit voir l'objet. -
La ressource validée revient au champ qui a ouvert le sélecteur, et les POI se posent dans l'éditeur three.js en iframe (décision n°9) — le vrai morceau non trivial du chantier.
⚠️ Ce lot faisait quatre lignes et se contentait de renvoyer au plan VR, qui lui-même renvoyait
ici. La frontière est tranchée depuis le 2026-09-11 dans
immersif-frontiere-plan.md, qui ajoute : Scene3D comme
GenerationKind (World Labs / Marble, API depuis janvier 2026, ~0,12 $ le monde draft),
provenance obligatoire et non désactivable sur un Model3D généré, ProviderResult
multi-fichier, et surtout cinq décisions qui touchent des lots antérieurs à celui-ci — dont le
pipeline d'ingestion unique (lot 0) et le renommage des crédits (lot 1). Les lire avant d'écrire
la première migration du Studio, pas en arrivant au lot 11.
Le test de réussite se joue à la fin du lot 4. Si à ce stade un conservateur ne produit pas 10 images cohérentes seul, les lots suivants n'y changeront rien.
7. Conflits signalés
| Intention | Réalité du code | Traitement |
|---|---|---|
| Générer côté serveur | Le serveur ne sait qu'effacer dans le bucket ; l'upload est fait par le navigateur | Lot 0, prérequis dur — et tout l'upload passe désormais par le serveur (décision 14) |
État publié |
La diffusion réelle dépend de GetReferencedResourceIds, pas d'une colonne |
Publié est dérivé, pas stocké |
| « Un asset non validé n'est jamais servi » | Une URL Firebase à jeton est publique pour qui a le lien | Préfixe studio-drafts/ + proxy authentifié |
| Plafond dur contre le stagiaire | Un plafond organisation n'empêche pas de vider le solde en un après-midi | Ajout d'un plafond par utilisateur et par jour |
| Quota IA existant | CheckQuota avant / incrément après ne tient pas sur des jobs parallèles |
Réservation en deux temps sur ledger |
| Modèles configurables | Le précédent (AI:ApiKey, modèle Gemini en dur) va dans l'autre sens |
Catalogue en base, éditable SuperAdmin |
| Souveraineté par région | Stockage GCS mono-bucket, LLM Gemini, embeddings Google | Champ déclaratif assumé, abstraction prête |
| 4 langues dont le luxembourgeois | LB absent des 4 repos |
Ajout dans le lot 0 |
| Escape game comme MVP | GuidedStep.ImageUrl est une URL, exclue de l'export offline |
Migration en ImageResourceId, lot 4 |
| Mention « généré par IA » | Un watermark gravé est irréversible et laid sur une illustration de musée | Provenance + badge UI ; gravure en option par instance |
| « L'onglet Studio = médiathèque » | showSelectResourceModal embarque ResourcesScreen : il n'y a déjà qu'une seule médiathèque, rendue à deux endroits. Une grille Studio en serait une troisième sur la même table |
Fusion. Ressources reste la bibliothèque unique (+ filtre IA, badge, provenance, filtre « non utilisée ») ; Studio garde l'identité, les personnages et les brouillons — voir §3.0 |
| « Boutons Générer dans chaque éditeur » | Tous les champs image passent par ResourceInputContainer → un point unique |
Un seul branchement dans ResourcesScreen/ResourceTab couvre les 13 types de section, les POI et les étapes |
| « Identité du musée » | Tous les clients ne sont pas des musées | L'onglet porte le nom de l'instance, puis celui de la configuration. Jamais un mot de catégorie |
| Sous-onglet « Usage & crédits » | Dupliquerait Abonnement, qui doit exister de toute façon — et qui n'apparaît aujourd'hui que si le plan est plan-essentiel |
Popover sur la jauge pour les chiffres, Abonnement pour le journal. Gating de l'entrée corrigé au lot 1 |
Avatar Studio vs talking head vs PersonaConfig |
Trois plans décrivaient le même objet sans se croiser | Une entité Persona. Le TTS en devient consommateur, le talking head est abandonné — voir §3.8 |
| « Le visiteur choisit entre Viva et Marco » | C'est un choix de voix déguisé en choix de guide. Le besoin réel (Bastogne) est N narrateurs assignés par le conservateur | Un guide adressable + N narrateurs. Et le stockage TTS est divisé par deux au lieu d'être multiplié |
Nom du guide libre + WakewordId |
Le visiteur dirait « Marco » à quelqu'un qui se présente comme « Léon » — GuideName est du texte libre sans lien avec le modèle OpenWakeWord |
Le wakeword est une exigence du canal mains-libres, pas une propriété du personnage. Nom libre sauf si lunettes/casque actifs |
| Talking head | talking-head-plan.md:11 repose sur enable_time_pointing de Cloud TTS, or le code tourne sur Gemini TTS : aucune source de timestamps |
Abandonné. Portrait canon statique, puis boucle vidéo (lot 10) |
| Moteur TTS | tts-pregenerated-plan.md dit Cloud TTS en en-tête et Gemini TTS dans sa section dual-persona — deux moteurs dans un document |
En-tête de correction posé sur le fichier. Le code (GeminiTtsEngine) est la référence |
| Intonation par personnage | kGeminiTtsPrompt est une constante de build (visitapp constants.dart:27) |
Devient Persona.VoicePrompt. Sans ça, quatre narrateurs sonnent pareil |
| Catalogue de voix | Deux constantes (Sulafat, Umbriel) | Table TtsVoice, obligatoire dès qu'il y a plus de deux personnages qui parlent |
| « Grille de silhouettes pré-générées » | Le style est verrouillé par l'identité : des silhouettes finies obligeraient à N poses × M styles | Grille d'archétypes neutres au trait — pose seulement, style toujours issu de l'identité |
| Canon = une image | Insuffisant pour un plan large ou un dos sur 15 étapes | Canon = jeu de vues (portrait + jusqu'à 3), envoyées ensemble, avec une jauge de budget car FLUX.2 plafonne à 10 références |
| Nouvel onglet | Deux switch numérotés en dur dans main_screen.dart |
Accepté tel quel, la refonte du routing n'est pas dans ce périmètre |
| Clé fal.ai | appsettings.json versionné contient déjà 4 secrets en clair |
Variable d'environnement, pas le fichier |
| Webhook « HMAC, comme Stripe » | fal signe en ED25519 avec des clés JWKS (doc fal, 13/09) | Vérification ED25519 sur le corps brut, idempotence sur request_id (§3.7) |
« Model3D = 11 » |
L'enum porte déjà Image360 11, Video360 12, Model3D 13 (12/09) |
Rien à ajouter à l'enum |
| Upload direct navigateur → bucket | manager-app n'a pas de Firebase Auth : les règles Storage sont très probablement ouvertes en écriture | URL V4 signée par l'API, écritures clientes fermées (décision 14) |
| « Tout par le serveur » (première version de la décision 14) | MemoryBufferThreshold = int.MaxValue (Startup.cs:112) tient tout IFormFile en RAM, et une vidéo 360 de 1 Go traverserait le VPS |
Abandonné le jour même : les octets vont directement chez Google |
| Objet écrit par le SDK GCS | Pas d'URL de téléchargement Firebase sans la métadonnée firebaseStorageDownloadTokens |
Jeton posé à l'écriture, URL construite (§5) |
Trois décisions d'immersif-frontiere-plan §5 « à écrire avant la première migration » |
Seule la n°5 (crédits) avait été reportée | N°1, 4 et 6 reportées le 13/09 : décisions 14, 20, 21 |
8. Exécutable — lots 0 à 4
Chaque lot dit ce qu'il suppose, ce qu'il touche et ce qui prouve qu'il est fini ; les pourquoi sont aux sections citées. Règles transverses :
- ⚠️
flutter analyzene suffit pas — seulflutter build webdit la vérité.- i18n : tout texte nouveau de manager-app passe par
AppLocalizations, clés dans les trois.arb(FR/EN/NL). Le serveur renvoie des codes d'erreur (insufficient_credits,upload_too_large…), jamais des phrases ; l'UI les traduit.- Client API :
manager_api_new/s'édite à la main, en miroir de chaque endpoint — ne jamais relancer le générateur.- Une migration EF par lot, nommée ci-dessous.
8.0 Prérequis
| Prérequis | État au 13/09 | Bloque |
|---|---|---|
| Migration Postgres en prod | faite en préprod (07/09), pas en prod | tout le Studio |
| Médiathèque V1 passée au navigateur | codée, checklist jamais passée | lot 0 (qui réécrit ses uploads), lot 5 |
Firebase:StorageBucket = mymuseum-3b97f.appspot.com |
vide | lot 0 — ⚠️ le jour où il est posé, Delete supprime vraiment ; versioning et soft delete sont déjà en place sur le bucket |
| Règles Storage actuelles relevées dans la console Firebase | jamais consultées ; manager-app écrit sans Firebase Auth, donc probablement ouvertes | fermeture des écritures (lot 0, Infra) — relever avant de toucher |
| Compte fal.ai, clé en variable d'environnement | absent | lot 3 |
Lot 0 — ingestion serveur
✅ Codé le 13/09 (backend et manager-app). Reste l'infra : bucket de dev, CORS, cycle de vie
incoming/, fermeture des règles après déploiement.
Backend
IResourceBlobService:CreateUploadUrl(storagePath, contentType, maxBytes)→ URL V4 signée enPUT, valable 15 min, en-têtesContent-Typeetx-goog-content-length-range: 0,{plafond}inclus dans la signature ;ReadAllAsync,UploadAsync,CopyAsync,GetInfoAsync. Toute écriture souspictures/pose la métadonnéefirebaseStorageDownloadTokens(§5). Bucket non configuré →503, jamais un succès muet.POST /api/Resource/upload-url: plafond du type (Storage:MaxUploadBytes:{ResourceType}, décision 18) et quota contrôlés sur la taille annoncée — un premier filtre, pas la vérité ; id réservé pour une création ; URL versincoming/{instanceId}/{resourceId}.ResourceIngestionService.IngestAsync(sourcePath, …)— le seul code qui range le fichier d'uneResource, quelle que soit sa provenance :incoming/pour un upload,studio-drafts/pour le Studio, un chemin temporaire pour le TTS. Dans l'ordre :GetInfoAsync→ plafond et quota sur la taille réelle (413, objet source supprimé) → post-traitement → écriture souspictures/{instanceId}/{resourceId}→ ligneResource(SizeBytes,Width,Height,FileName,StoragePath,Url,Origin,DateUpdate) → suppression de l'objet source. Échec à l'écriture de la ligne → blob rangé supprimé : plus de ligne sans fichier ni de fichier sans ligne (anomalie de media-storage-plan.md §3).- Post-traitement image en ImageSharp, aux règles exactes d'
ImageCompressor.dart, qui fait foi : 2560 px côté long, JPEG q82, PNG à alpha gardé en PNG, résultat plus lourd que l'original → original conservé,Image360/Video360/Model3Djamais touchés. Aucun watermark : sorti du lot 0, voir la carte kanban 305 (il était inactif depuis l'upload direct). Les autres types passent tels quels. - Ce que le serveur lit, et ce qu'il ne lit pas. Une image (≤ 30 Mo) est lue depuis le bucket,
traitée en mémoire, et au plus 2 images sont traitées en même temps (sémaphore) : un dépôt de
50 photos ne ralentit pas l'API. Vidéo, audio, documents, 360 et GLB ne sont jamais lus :
CopyAsynccôté Google, puis suppression de la source. - Route
POST /api/Resource/ingest(§4). SupprimerResourceController.UploadetImageHelper(System.Drawing) ; si plus rien n'en dépend, retirerMemoryBufferThreshold = int.MaxValuedeStartup.cs:109-113. - Migration
AddResourceOrigin:Origin int not null default 0,SourceResourceId text null;ToDTO()expose les deux. LBdansSupportedLanguages(appsettings.json), dansconstants.dartde manager-app, et son drapeau.
Infra (bucket mymuseum-3b97f.appspot.com)
- CORS : origines du manager (préprod, prod), méthode
PUT, en-têtesContent-Typeetx-goog-content-length-range— sans quoi le navigateur refuse l'envoi. - Cycle de vie : règle
age: 1sur le préfixeincoming/— un envoi jamais ingéré disparaît seul, sans job Hangfire. S'ajoute à la règle des versions non courantes posée le 07/09. - Règles Storage : écriture fermée à tous les clients, lecture inchangée. Relever les règles actuelles avant. ⚠️ À appliquer après la mise en prod du nouveau manager-app : fermer avant casse l'upload de la version encore déployée.
manager-app
createet_replaceFile(resources_screen.dart) :upload-url→PUTdirect avec lesrequiredHeaders(progression affichée) →ingest.ImageCompressorreste côté client (≈ 12× moins d'octets à monter) ; le serveur réapplique les mêmes règles, sans effet sur une image déjà traitée.- Plus aucun appel au SDK Firebase Storage : supprimer
_deleteBlob, le pré-contrôle de quota client (le serveur fait foi :413→ message traduit) et la dépendancefirebase_storage.
| # | Vérifiable par |
|---|---|
| 1-2 | dotnet test : quota refusé sur la taille annoncée, puis sur la taille réelle quand le client a annoncé 0 ; objet source supprimé après 413 ; ligne en échec ⇒ blob rangé supprimé |
| 3-4 | dotnet test : JPEG 4000 px → 2560 ; PNG à alpha → PNG ; Image360 intact ; image déjà petite → octets identiques ; vidéo copiée, jamais lue |
| 1 | URL expirée, ou fichier au-delà du plafond signé → Google refuse le PUT |
| Infra | Préprod, vidéo de 450 Mo : docker stats — ni la RAM ni le réseau de l'API ne bougent |
| Infra | Règles fermées : un putData Firebase sans URL signée est refusé |
| 5-6 | Swagger : POST /upload a disparu ; migration appliquée en préprod |
| 8-9 | flutter build web ; créer, remplacer, supprimer une image, un PDF, un MP3, une 360 ; chaque URL s'ouvre dans visitapp-web et dans l'app mobile |
| — | Remplacer une image d'une visite déjà téléchargée : l'app la re-télécharge |
| — | Fichier au-dessus du plafond de son type → 413, message traduit en FR, EN et NL |
| — | Non-régression du sélecteur de ressource (plan Médiathèque §3.3) : champ image d'une section, ajout d'un fichier depuis la modale, enregistrer |
Lot 1 — crédits
✅ Codé le 13/09. Écarts : décision 22.
- Migration
AddStudioCredits: colonnesInstance/SubscriptionPlan(§5), tableCreditLedgerEntries. StudioCreditService:Reserve,Charge,Refund,Grant,Expire.Reservesous verrou de ligne surInstance, dans la transaction qui écrit le ledger (§3.5). Contrôles dans l'ordre :StudioEnabled→ plafond utilisateur/jour → solde disponible. Refus en codes (studio_disabled,daily_cap,insufficient_credits).Grant: solde augmenté,StudioCreditsExpireAt = now + 12 mois(décision 17).- Job Hangfire quotidien
StudioCreditExpiryService: solde échu → ligneExpiredu montant perdu, solde à 0 ; instance à réservations ouvertes sautée, reprise au passage suivant. GET /credits,GET /credits/ledger(CSV),POST /credits/grant(§4).- manager-app : troisième jauge du pied de menu + popover (solde, réservé, disponible, expiration,
votre usage du jour) ; journal et export CSV dans Abonnement ; écran SuperAdmin de
Grant; gating de l'entrée Abonnement corrigé (main_screen.dart:712-714).
| # | Vérifiable par |
|---|---|
| 2 | Test : 10 Reserve parallèles sur un solde qui en couvre 3 → exactement 3 acceptées |
| 2 | Test : Reserve puis Refund → solde et disponible identiques à l'avant, deux lignes au journal |
| 3-4 | Test : Grant en janvier puis en septembre → une seule date, septembre + 12 mois ; job passé à date échue → ligne Expire, solde 0 |
| 6 | Instance Premium : l'entrée Abonnement apparaît ; un ContentEditor voit le popover, pas l'Abonnement |
Lot 2 — identité visuelle
✅ Codé le 13/09 — backend et écran Studio du menu. Écarts : décision 23.
- Migration
AddVisualIdentities+ seed du catalogue de styles v0 (§9.1). - Service : identité de base créée vide au premier accès, non supprimable ;
Resolve(instanceId, configurationId);duplicate= copie complète +CopiedFromIdentityId/Version;Version++à chaque save ;PromptPreambleetNegativeFragmentrecalculés au save selon §9.2. - Endpoints « Identité visuelle » du §4, sauf
preview, qui génère (lot 3). - manager-app : entrée Studio › [nom de l'instance], puis un onglet par configuration surchargée ;
blocs style · contexte · palette · exclusions · références (1 à 5, choisies dans la Médiathèque) ;
badge « modifié » et « Reprendre depuis [instance] » par bloc. Visible seulement si
StudioEnabled(qui impliqueIsAssistant, décision 12).
| # | Vérifiable par |
|---|---|
| 2 | Tests : Resolve sans surcharge → base, avec → configuration ; suppression de la base refusée ; PromptPreamble d'un jeu de champs fixe = texte attendu (fige le §9.2) |
| 4 | flutter build web ; créer une surcharge « Halloween », changer la palette, voir le badge, reprendre depuis l'instance |
Lot 3 — fournisseur, génération, calibrage
✅ Codé le 13/09, sauf l'étape 7 : le calibrage et les prix attendent la clé fal.ai. Écarts : décision 23.
- Migration
AddGeneration:GenerationModels,GenerationTemplates,GenerationJobs,GeneratedAssets,Resource.AiProvenance,User.CanValidateAssets; seedimage-default(§3.6) et gabaritpuzzle-decor(§9.3). IGenerationProviderau contrat multi-fichier +FalAiGenerationProvider; clé en variable d'environnement.PromptAssembler: ordre du §3.3 ; références dans l'ordre canon → identité → source, tronquées auMaxReferenceImagesdu modèle, et la troncature renvoyée (references{…, truncated}).EffectivePrompttoujours stocké.POST /estimate,POST /generate,GET /jobs/{id},GET /jobs,POST /jobs/{id}/cancel; job Hangfire (un id, jamais l'objet) ; webhook ED25519 (§3.7) ; relance de secours à +90 s.studio-drafts/+GET /assets/{id}/contentauthentifié ;discard,reject,regenerate; balayage Hangfire à 30 jours.POST /assets/{id}/validatesousPolicy = AssetValidation→ResourceIngestionService(Origin = Generated) ; permissionManager.assetvalidation(§3.10).AiProvenances'écrit ici ; sa descente vers les clients visiteurs reste au lot 6.- Calibrage (§9.4), puis unité, packs et
CreditCostfixés sur les coûts mesurés et écrits dans ce plan (décision 16).
| # | Vérifiable par |
|---|---|
| 3 | Test : identité à 5 refs + source, modèle à MaxReferenceImages = 4 → 4 envoyées, truncated = true, canon jamais tronqué avant l'identité |
| 4 | Tests : signature invalide → 401 ; même request_id reçu deux fois → un seul Charge ; échec fournisseur → Refund |
| 5 | Un brouillon n'a aucune URL publique : chemin sous studio-drafts/, content refusé sans jeton |
| 6 | ContentEditor sans permission → 403 sur validate ; avec → Resource créée, Origin = Generated, quota compté à ce moment et pas avant |
| 7 | Critères du §9.4 tenus ; prix écrits |
Lot 4 — « Générer » dans le sélecteur, et le gate
✅ Codé le 13/09 (étapes 1 à 4, migration
AddGuidedStepImageResource). Gate non passé. Écarts : décision 23.
- Onglet « Générer » dans
ResourceTab/showNewResource: un seul branchement (§3.0), qui apparaît dans les 13 types de section, les POI, les étapes et la Médiathèque. Absent siStudioEnabled == false. - Panneau : gabarit → 2-3 champs → point de départ (texte | image, décision 15) → coût estimé →
4 variantes → valider → affectation au champ si
targetest connu. Polling à 2 s tant que le job est ouvert ; fermer le panneau →discarddes variantes non retenues. GuidedStep.ImageUrl→ImageResourceId: migrationGuidedStepImageResource(retrouver laResourcepar URL, sinon en créer une de typeImageUrl), inclusion dansGetReferencedResourceIds, lecture du nouveau champ par les trois clients visiteurs, et retrait de la limite dans le tooltip « Jamais utilisées » de la Médiathèque.- Gabarits
historical-objectetscene-evocationajoutés au seed, calibrés comme au lot 3.
| # | Vérifiable par |
|---|---|
| 1 | Non-régression du sélecteur : ouvrir un champ image, choisir une ressource existante, enregistrer |
| 2 | Générer depuis un champ d'étape : l'image validée est posée dans le champ sans autre geste |
| 3 | Une étape à image générée part dans l'export offline et s'affiche hors ligne ; elle n'apparaît plus dans « Jamais utilisées » |
| Gate | Un conservateur, seul devant l'écran, produit 10 images cohérentes. Résultat consigné dans DOCS/test-plan*.md, rapporté par Thomas. Échec → on reprend le §9 et le panneau ; le lot 5 ne s'ouvre pas |
9. Catalogue v0 — styles, préambule, gabarits
Seed de départ, pas un contenu validé (décision 19). Tout vit en base, éditable en SuperAdmin, et le calibrage de fin de lot 3 (§9.4) le corrige avant qu'un client ne le voie. Les textes destinés au modèle sont en anglais — les modèles image y répondent mieux, et ils ne sont jamais affichés. Les libellés visibles sont des
List<TranslationDTO>FR/EN/NL.
9.1 Styles
StyleKey |
Libellé FR | Fragment injecté |
|---|---|---|
illustration-painterly |
Illustration peinte | painterly digital illustration, visible brush strokes, soft edges, rich but controlled colors |
ligne-claire |
Ligne claire | ligne claire illustration, clean uniform outlines, flat colors, no gradients |
engraving |
Gravure ancienne | antique copperplate engraving, fine cross-hatching, black ink only on aged off-white paper, no color |
watercolor |
Aquarelle | loose watercolor painting, transparent washes, visible paper texture, soft bleeding edges |
gouache-poster |
Affiche gouache | mid-century gouache poster, bold simplified shapes, limited palette, subtle grain |
diorama |
Maquette / diorama | handcrafted miniature diorama, scale model, tilt-shift, warm studio lighting |
children-book |
Album jeunesse | children's book illustration, friendly rounded shapes, gentle colors, clear silhouettes |
photorealistic |
Photoréaliste | photorealistic, natural lighting, realistic materials, 35mm lens |
photorealistic porte un avertissement dans l'écran : sur un sujet historique, le photoréalisme se
lit comme une archive. Proposé, jamais par défaut.
9.2 Assemblage du préambule et du négatif
Calculés au save de l'identité, dans cet ordre, fragments vides sautés :
PromptPreamble =
"{style.fragment}. "
+ "Setting: {Era}, {Region}. "
+ "Materials: {Materials, joined ', '}. "
+ "Lighting and mood: {LightingMood}. "
+ "Color palette: {Palette → noms de couleurs}. " -- hex convertis en noms ("deep ochre") par une
-- table serveur : un modèle lit mal les hex
+ "Keep a consistent visual style with the reference images." -- si ReferenceResourceIds ≠ ∅
NegativeFragment =
"text, letters, captions, signatures, watermarks, logos, stamps, plate numbers" -- toujours, sans « no » (§9.4)
+ ", {Exclusions, joined ', '}"
Puces d'exclusion prédéfinies : anachronisms, modern-objects, gore, nudity, logos-brands,
real-people (« aucune personne réelle identifiable »), et text-in-image, affichée cochée et
verrouillée puisqu'elle est déjà dans le négatif de base.
✅ Vérifié le 15/09 sur le schéma OpenAPI fal : ni flux-2-pro ni flux-2-pro/edit n'ont de negative_prompt.
Le négatif part dans le prompt sous la forme Avoid: … — d'où un négatif rédigé sans « no ».
9.3 Gabarits du MVP
Contexte de fiche (§3.3, point 4), ajouté après le corps de chaque gabarit :
"Background knowledge, never written in the image: {titre de la fiche}. {description, 300 caractères max}"
(« Context: » seul faisait écrire le titre en légende, §9.4), dans la langue par
défaut de la configuration.
puzzle-decor — Décor d'énigme · AcceptsPersona = false · landscape_16_9 · 4 variantes ·
AppliesTo = [GuidedStep, SectionArticle, SectionGame]
| Champ | Type | Requis | Aide affichée |
|---|---|---|---|
place |
text | oui | « Où se passe la scène ? » — la salle des gardes du donjon |
clue |
text | non | « Un détail qui doit rester visible pour l'énigme » — une clé sous la troisième dalle |
atmosphere |
enum | oui | mystérieuse · calme · tendue · ludique (mysterious · calm · tense · playful) |
"An escape game environment: {place}. The atmosphere is {atmosphere}. "
+ [clue] "A small but clearly visible detail matters for the puzzle: {clue}. "
+ "Wide shot, no people, the scene invites exploration."
historical-object — Objet d'époque · AcceptsPersona = false · square_hd · 4 variantes ·
AppliesTo = [SectionArticle, GeoPoint, GuidedStep]
| Champ | Type | Requis | Aide affichée |
|---|---|---|---|
object |
text | oui | « Quel objet ? » — un coffre de marchand ferré |
condition |
enum | oui | neuf, tel qu'à l'époque · usé par le temps (new · worn) |
presentation |
enum | oui | isolé sur fond neutre · en situation (isolated · in-context) |
"{object}, " + (new ? "as it looked when newly made. " : "with authentic wear and age. ")
+ (isolated ? "Isolated on a plain neutral background, centered, whole object visible."
: "Shown in its original context of use.")
+ [source, subject] " Reproduce the exact object of the source image — shape, proportions,
ornaments; only the rendering style changes."
scene-evocation — Évocation de scène · AcceptsPersona = true (effectif au lot 7) ·
landscape_16_9 · 4 variantes · AppliesTo = [SectionArticle, GeoPoint, GuidedStep]
| Champ | Type | Requis | Aide affichée |
|---|---|---|---|
action |
text | oui | « Que se passe-t-il ? » — des ouvriers coulent la fonte au haut-fourneau |
place |
text | oui | « Où ? » |
shot |
enum | oui | plan large · plan moyen (wide · medium) |
(wide ? "Wide establishing shot" : "Medium shot") + " of {action}, at {place}. "
+ "Figures are small or seen from behind, no recognizable faces, "
+ "historically plausible clothing and tools."
« Pas de visage reconnaissable » tient jusqu'au lot 7 : sans canon, un visage change d'une image à l'autre et casse la cohérence plus sûrement que n'importe quel style.
9.4 Calibrage (fin du lot 3)
Une identité de test par style visé pour les premiers clients — au minimum engraving, watercolor,
illustration-painterly — sur des sujets du Fort et du Fourneau.
| Série | Générations | Critère de sortie |
|---|---|---|
| Cohérence | 10 puzzle-decor sous une même identité |
8/10 jugées « du même projet » à l'aveugle |
| Références | la même série avec 0, 3 puis 5 refs d'identité | l'écart se voit — sinon les refs ne servent à rien et le budget du §3.3 est à revoir |
| Image source | 5 objets réels en subject |
l'objet reste reconnaissable dans 4 cas sur 5 |
| Négatif | 10 images | aucun texte parasite dans 9/10 ; sinon forme Avoid: ajustée |
| Modèle | MaxReferenceImages, forme du négatif, ids fal-ai/flux-2-pro et /edit |
consigné dans GenerationModel |
| Coût | coût réel par image, selon le nombre de références | base de CreditCost (décision 16) |
Sortie : les textes des §9.1-9.3 corrigés dans ce document, puis re-seedés.
Résultats du 2026-09-15 — 61 générations, ≈ 3,13 $ estimés
Identités de test : A gravure / Fourneau Saint-Michel (refs = planches « Forges » de l'Encyclopédie, domaine public), B aquarelle / Fort de Saint-Héribert, C illustration peinte / Fourneau. Objets : photos Wikimedia libres. Le jugement « à l'aveugle » est celui de Claude sur planches contact : à refaire par Thomas avant d'ouvrir à un client.
| Série | Résultat | Suite |
|---|---|---|
| Cohérence | A 10/10, B 5/5, C 5/5 lus « du même projet » | ✅ |
| Références 0/3/5 | Écart net : avec refs, vraie planche monochrome ; sans, gravure colorisée (lueur rose, sépia). 3 → 5 refs : gain faible pour +50 % de coût. Les refs apportent aussi leurs numéros de planche et légendes | budget §3.3 tient ; 3 refs suffisent pour un style |
| Image source | 5/5 objets reconnaissables. Mais le négatif « no text » effaçait la date 1703 d'une taque | instruction sujet complétée : garder les inscriptions de l'objet — date revenue ✅ |
| Négatif | v1 « Avoid: no text… » : ~4/10 avec légende/signature. v2 sans « no » + « captions, signatures, plate numbers » : 3/10. v3 + contexte « never written in the image » : 0/10 visible | ✅ les trois corrections sont dans le code et le seed |
| Modèle | plafond 10 refs mesuré ; pas de negative_prompt ; /edit accepte landscape_16_9 |
consigné |
| Coût | 0,03 ** l'image (16:9 comme carré : fal compte 1 MP = 1024²) **+ 0,015 par référence : 0,045 avec 1 source · 0,075 3 refs · 0,105 5 refs · 0,18 10 refs |
voir ci-dessous |
Coûts recoupés sur l'export d'usage fal du 15/09 : 44 MP flux-2-pro (1,32 ) + 57,5 MP `/edit` (1,725 ) = 3,05 $,
dont ≈ 0,30 de portraits faits à la main — le calibrage lui-même a coûté ≈ 2,75 (estimé 3,13 $ avec l'ancienne grille).
Unité arrêtée le 2026-09-15 : 1 crédit = 0,015 $ de coût fal. CreditCost = 2 par image,
CreditCostPerReference = 1 par référence (identité, canon, source). Ex. 3 variantes × 3 refs = 15 crédits ≈ 0,23 $.
Prix de vente = coût × marge, jamais le coût nu : proposition ≈ 0,05 €/crédit (×3,3), packs dégressifs
200 = 12 € · 600 = 30 € · 2 000 = 80 € (pire marge ×2,7). La marge couvre aperçus, régénérations, change USD,
Stripe, stockage et support. Prix en € à confirmer avec la recharge Stripe (après le gate du lot 4).
Anachronismes vus malgré l'exclusion : poêle à bois moderne (A-03 ×2), vélo (refs3 v2), plafonniers (B). À surveiller.