1328 lines
82 KiB
Markdown
1328 lines
82 KiB
Markdown
# 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 |
|
||
| 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, 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<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, 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 |
|