DOCS/v2/studio-plan.md
Thomas Fransolet 29da4fcb83 Contenu immersif : la frontiere Studio IA / XR est tranchee
Les deux plans se renvoyaient la balle exactement sur la zone a
construire : studio-plan mettait la 3D en lot 11 en disant "depend du
chantier VR", et vr-quest-unity-plan decrivait les POI sur GLB en
supposant un GLB qui arrive de quelque part. Nouveau document de
frontiere, plus les corrections qu'il impose au plan Studio.

v2/immersif-frontiere-plan.md (neuf)
- Trois couches separees : ressources immersives (360/GLB, se vendent
  sans casque), consommation (SectionModel3D, ImmersiveBackground, app
  Quest), et le Studio comme *source d'approvisionnement* de la couche 1
  et non module parallele.
- Pas d'add-on casque : un seul add-on "Contenu immersif" a 70 EUR, plus
  le hardware et l'onboarding one-shot. Reconfirme l'arbitrage du 31/08.
- Generation de scene : position revisee. Marble (World Labs) a une API
  depuis janvier 2026, ~0,12 $ le monde draft, export GLB. Cadre retenu
  = roomscale stationnaire ~3 m, pas d'environnement navigable : ~500k
  splats de plafond sur Quest 3 standalone, et un cas mesure a 12 fps
  sous Unity la ou une borne doit tenir 72.
- 11 decisions de retro-compat ordonnees par cout de retrofit. Les cinq
  premieres touchent des lots ANTERIEURS a la 3D : pipeline d'ingestion
  unique (l'upload est fait par le navigateur, donc un GLB uploade ne
  traverse aucun traitement alors qu'un GLB genere si), normalisation
  canonique des GLB, invalidation des POI, lignage en colonnes typees,
  renommage des credits.
- Mecaniques de jeu immersives notees, rien de planifie : repondre
  (SectionQuiz existe deja), trouver (marche aussi en web sur pano
  pivotant, donc se vend sans casque), ordonner
  (OrderedTranslationAndResource existe deja), avant/apres.

v2/studio-plan.md
- ImageCredits* -> StudioCredits* : une 3D coute 10 a 50 fois une image,
  le nom aurait menti des le lot 11.
- Credits rechargeables a expiration 12 mois au lieu d'un quota mensuel.
  Un musee genere 200 images en trois semaines pour une expo puis rien
  pendant cinq mois ; un forfait mensuel se gaspille dix mois sur douze.
  Le CreditLedger encaisse sans changement de structure.
- Le critere qui separe les deux monnaies, verifie dans le code :
  consomme en temps reel par le visiteur (chat, vocal) contre produit
  une fois par le client (images, 3D, video, TTS pre-genere). Le TTS
  passe donc cote Studio. Au passage : reindex et insights ne debitent
  rien alors qu'ils coutent.
- Lot 11 ne renvoie plus en boucle au plan VR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 15:44:42 +02:00

85 KiB
Raw Blame History

Module Studio — Génération IA d'images, vidéos et 3D

Statut : conception arrêtée le 2026-09-02. Rien de codé.

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 AppContextManagerAppContext (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, XxxDatabaseService par entité.
  • Auth JWT (manager) + X-Api-Key (visiteurs). 4 rôles en dur → 4 permissions → 4 policies (Security.cs).
  • Hangfire bien installé : IBackgroundJobClient injecté, jobs prenant un id, jamais un objet. SectionIndexingInterceptor dé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 dans appsettings.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, Date*, Ai* }

  • Stockage Firebase/GCS, chemin pictures/{instanceId}/{resourceId}.
  • L'upload est fait par le navigateur, en direct (resources_screen.dart:247-256). Le backend ne sait que supprimer (IResourceBlobService.DeleteAsync).
  • ResourceController.Upload (base64 + watermark) est du code mort : System.Drawing.Common ne tourne pas sur aspnet:8.0 Linux.
  • 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 9)
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

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 : mois, réservés, disponibles, votre jour, plafond dur. 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 ContentEditor qui 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:547-550) — 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
Tuiles carrées de 160 px, ~300 d'un bloc, sans ordre resource_body_grid.dart:78SliverGridDelegateWithMaxCrossAxisExtent(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 » est presque gratuit. GetReferencedResourceIds(language) est déjà implémentée sur les 13 sous-types plus GuidedPath/GuidedStep : l'index inverse se construit en parcourant les sections de l'instance. Deux endpoints suffisent :

GET /api/Resource/{id}/usages            → [{ kind, id, label, configurationId, field, path }]
GET /api/Resource/usage-map?instanceId=  → { resourceId: count }   -- pour les badges de la grille

Trois pièges à ne pas rater dans l'implémentation :

  1. GetReferencedResourceIds prend une langue — il faut l'union sur toutes les langues de la configuration, sinon une image posée seulement en NL passerait pour orpheline.
  2. Configuration.ImageId et LoaderImageId sont hors sections (voir ConfigurationController.cs:407-414) : à inclure, sans quoi l'image d'accueil d'une configuration s'afficherait « jamais utilisée ».
  3. GuidedStep.ImageUrl est une URL, pas un id : une image posée là compterait comme orpheline — énième argument pour la migration en ImageResourceId prévue au lot 4.

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
                                   ├─ contrôle quota stockage  ◄─ le mur tombe ICI
                                   ├─ compresse (ImageSharp 2560/q82)
                                   ├─ copie vers pictures/{instanceId}/
                                   ├─ 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 :

  1. Préambule du modèle (GenerationModel.Params)
  2. VisualIdentity.PromptPreamble — style, époque, région, matériaux, lumière, palette en mots
  3. Corps du gabarit, champs métier substitués
  4. Contexte du contenu — titre + description de la fiche (POI, étape, objet), tronqués
  5. NegativeFragment — exclusions + « pas de texte dans l'image » par défaut
  6. Le personnage, s'il en apparaît un — voir ci-dessous
  7. 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.

⚠️ 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 : contrôle le quota stockage (413 ici, pas à la génération) → compresse en 2560 px / q82 (ImageSharp, pas System.Drawing) → copie → crée le Resource avec sa provenance → l'affecte au champ cible s'il y en avait un.

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

SubscriptionPlan  +=  HasStudio, StudioCreditsGranted    -- dotation à la souscription
Instance          +=  StudioEnabled, StudioCreditsBalance, StudioCreditsExpireAt,
                      StudioCreditsHardCap, StudioPerUserDailyCap, StudioProviderRegion

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 nom ImageCredits aurait menti dès le lot 11. GenerationModel.CreditCost porte 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 le Kind: MonthlyReset. Apparaissent : StudioCreditsBalance, StudioCreditsExpireAt, et un Kind: Expire pour tracer la péremption dans le journal. Le CreditLedger encaisse le modèle sans changement de structure — il était déjà append-only avec un Kind: Grant.

⚠️ Reste ouvert : la recharge côté Stripe. Le webhook ne gère que checkout.session.completed et invoice.payment_failed (StripeWebhookController.cs:62-65), rien sur les lignes d'abonnement. Un achat de crédits est un paiement ponctuel, donc checkout.session.completed suffit — mais le mapping session → Kind: Grant est à écrire. Pour les premiers clients, un Grant posé à la main depuis l'écran SuperAdmin suffit.

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 budget du mois est justement là pour être dépensé sur le mois. Deux garde-fous distincts :

  • StudioCreditsHardCap — plafond organisation que même un dépassement facturé ne franchit pas.
  • StudioPerUserDailyCap — le vrai rempart : N générations par utilisateur et par jour.

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 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);
}

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
  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, 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 Meshy 6 ou Tripo objets isolés uniquement, export GLB

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      (HMAC vérifié — précédent : StripeWebhookController)
  → télécharge les octets, écrit studio-drafts/, crée GeneratedAsset ×N
  → Charge(crédits réels), Status=Succeeded

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.

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) et PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName } (tts-pregenerated-plan.md). Et le code diverge déjà du plan : Instance porte un guide au singulier (GuideName, GuidePersonaPrompt, GuideVoiceId, GuideFallbackMessages) là où tts-pregenerated-plan en 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
VoiceId narrer 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 :

  • SystemPrompt est écrit dans une seule langue — le modèle répond dans celle du visiteur. À ne pas confondre avec les Params de 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.
  • FallbackMessages reste un List<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.
  • WakewordId est 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 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-20kGuideVoiceViva / kGuideVoiceMarco table TtsVoice
L'intonation, constante de build mymuseum-visitapp/constants.dart:27kGeminiTtsPrompt 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 InstanceConfigurationGuidedPath / SectionMap GuidedStep, GeoPoint, SectionArticle
Guide InstanceConfiguration

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 :

  1. Hérité et surchargé se distinguent à l'œil : ↳ Le gouverneur en 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.
  2. « 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.
  3. 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.
  4. 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.

Un seul cas justifierait 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. Table optionnelle ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured }, remplie seulement si cet écran existe. Hors périmètre tant que la décision produit n'est pas prise.

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éshey_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'ids Resource par 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-plan pré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 Persona remplacent les colonnes à plat, la migration déplace les valeurs existantes dans une ligne Kind = Guide.

Ce qu'il ne faut surtout pas fusionner

  1. Le wakeword. On appelle un guide, on n'appelle pas le gouverneur. Jamais exposé sur un Character — et un nom custom reste un add-on facturable (entraînement OpenWakeWord).
  2. SystemPrompt et Params. « 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.
  3. 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

  1. Versionner les facettes séparémentCanonVersion et VoiceVersion. Sans ça, éditer un costume régénérerait 12 fichiers audio, et changer une voix invaliderait 23 images. tts-pregenerated-plan pré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.
  2. 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 :

  1. 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.
  2. 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.
  3. 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.
  4. 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 que VisualIdentity.Version. Et on archive un personnage, on ne le supprime pas : AiProvenance pointe dessus.
  5. 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.md porte 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 9, 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.

→ Le talking head devient un consommateur de Persona : le guide (Instance.GuideName / GuidePersonaPrompt / GuideVoiceId) gagne un AvatarId, la frame 0 est le portrait canon, les frames 1-2 sortent du pipeline Studio avec son catalogue de modèles en base. Un pipeline en moins, un modèle en dur en moins, et surtout un seul mot « avatar » dans le back-office — deux objets nommés pareil auraient été une confusion permanente.

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 à SuperAdmin et InstanceAdmin.
  • Accordable individuellement à un ContentEditor (nouveau bool CanValidateAssets sur User).
  • Sur une instance à un seul utilisateur — le cas courant — l'InstanceAdmin l'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 }
  • rightsHolder par 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.
  • AiProvenance != null suffit à déclencher le badge « image générée par IA » côté visiteur.
  • 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 ResourceDTOExportConfigurationDTO → 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

Crédits

GET    /api/Studio/credits?instanceId=             → { perMonth, used, reserved,
                                                       available, hardCap,
                                                       perUserDailyCap, usedToday }
GET    /api/Studio/credits/ledger?from=&to=&format=csv

Le premier alimente la jauge et son popover, le second l'écran Abonnement. Pas de sous-onglet Studio.

Médiathèque — usages (§3.0bis)

GET    /api/Resource/{id}/usages            → [{ kind, id, label, configurationId, field, path }]
GET    /api/Resource/usage-map?instanceId=  → { resourceId: count }

ResourceDTO gagne fileName (déjà en base, absent de ToDTO()), width, height, usageCount.

Webhook

POST   /api/StudioWebhook/fal                      [AllowAnonymous] + HMAC

Administration

GET/PUT /api/Studio/admin/models                   [Policy = SuperAdmin]
GET/PUT /api/Studio/admin/templates                [Policy = SuperAdmin]

5. Schéma de données

Une migration, additive, aucune donnée existante touchée — sauf GuidedStep (voir plus bas).

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, Kind, index (InstanceId, IsArchived) — remplace les colonnes Guide* de Instance
PersonaViews PersonaId FK cascade, ResourceId FK
TtsVoices Key unique — catalogue, plus deux constantes
BeforeAfterPairs AfterResourceId, BeforeResourceId
CreditLedgerEntries InstanceId, UserId, GenerationJobId?, index (InstanceId, CreatedAt)

Colonnes ajoutées

Resource          += AiProvenance jsonb?
Instance          += StudioEnabled, StudioCreditsBalance, StudioCreditsExpireAt,
                     StudioCreditsHardCap, StudioPerUserDailyCap, StudioProviderRegion,
                     IsAiWatermarkBurned
SubscriptionPlan  += HasStudio, StudioCreditsGranted
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      += Model3D            -- valeur 11, EN FIN D'ENUM, jamais au milieu

Corrections d'existant que le module rend nécessaires

  • IResourceBlobService : ajouter UploadAsync, CopyAsync, ProbeSizeAsync. Le credential est déjà chargé pour FCM ; seul Firebase:StorageBucket est vide en config.
  • GuidedStep.ImageUrlImageResourceId, et l'inclure dans GetReferencedResourceIds. 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) et constants.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 :

../v1-mediatheque-plan.md

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 serveur (prérequis dur)

IResourceBlobService.UploadAsync/CopyAsync, config du bucket, ImageHelper en ImageSharp, compression serveur 2560/q82, ajout de LB. 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, export CSV. Crédits rechargeables à expiration, pas de reset mensuel (§3.5) : le balayage de péremption est un job Hangfire, précédents en place (AuditLogPurgeService). 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:547-550), 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, FalAiGenerationProvider, catalogue GenerationModels en base, POST /generate, job Hangfire, webhook HMAC, polling de secours. Un seul gabarit pour commencer : puzzle-decor.

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.ImageUrlImageResourceId ici, sinon rien ne part offline. Gabarits puzzle-decor, historical-object, scene-evocation.

Lot 5 — Greffe du Studio sur la Médiathèque

La refonte elle-même est en V1 (lot V1-A). 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.

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.

Migration structurante : les 4 colonnes Instance.Guide* deviennent une ligne Persona { Kind = Guide }, 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.

  • NarratorPersonaId sur GuidedStep / 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 table TtsVoice.
  • 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.

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.

Lot 11 — 3D

Meshy 6 / Tripo, objets isolés, export GLB, ResourceType.Model3D. Suppose un viewer GLB côté visiteur — dépend du chantier VR/XR (vr-quest-unity-plan.md).

⚠️ 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
É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 dépenser le budget du mois 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