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