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

1367 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../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](tts-pregenerated-plan.md) et annule
> [talking-head-plan.md](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, `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 | 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 » 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](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](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
```csharp
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 | 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 :
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é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'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ément**`CanonVersion` 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 `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
```
### 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.ImageUrl``ImageResourceId`, 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](../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.ImageUrl` → `ImageResourceId` 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](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 |