Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
1932 lines
133 KiB
Markdown
1932 lines
133 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). **Reste** : les points de carte (fenêtre ancienne, sans champ audio). ⚠️ Écran jamais affiché : analysé, pas lancé. **8d** — portrait canon côté visiteur, 3 fronts.
|
||
|
||
### 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.**
|
||
|
||
### 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.
|