# 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 { 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 | | 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 -- hex Exclusions jsonb List -- puces prédéfinies + libre ReferenceResourceIds jsonb List -- 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 Kind -- Image | Video | Model3D Fields jsonb List -- 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. ### 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, ImageCreditsPerMonth Instance += StudioEnabled, ImageCreditsPerMonth, ImageCreditsThisMonth, ImageCreditsMonthKey, ImageCreditsHardCap, StudioPerUserDailyCap, StudioProviderRegion CreditLedger (append-only, exportable CSV) Id, InstanceId, UserId, GenerationJobId?, Kind, Amount, BalanceAfter, ModelKey, CreatedAt, Note Kind: Reserve | Charge | Refund | Grant | MonthlyReset ``` **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 : - `ImageCreditsHardCap` — 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 = `ImageCreditsPerMonth − (charges + 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 SubmitAsync(GenerationRequest req, CancellationToken ct); Task 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(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` **à 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 .NarratorPersonaId -- la surcharge, si posée ?? .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` 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, ImageCreditsPerMonth, ImageCreditsThisMonth, ImageCreditsMonthKey, ImageCreditsHardCap, StudioPerUserDailyCap, StudioProviderRegion, IsAiWatermarkBurned SubscriptionPlan += HasStudio, ImageCreditsPerMonth 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. 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`). **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 |