DOCS/v2/studio-plan.md
Thomas Fransolet f9d5e9cb6b Studio : lot 8d et plan de test de la narration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 22:18:23 +02:00

133 KiB
Raw Blame History

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 : 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 §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. 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 et annule 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 AppContextManagerAppContext (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 serveurré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
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
Tuiles carrées de 160 px, ~300 d'un bloc, sans ordre resource_body_grid.dart:78SliverGridDelegateWithMaxCrossAxisExtent(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 §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).

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 §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

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,020,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 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-20kGuideVoiceViva / kGuideVoiceMarco table TtsVoice
L'intonation, constante de build mymuseum-visitapp/constants.dart:27kGeminiTtsPrompt 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 InstanceConfigurationGuidedPath / SectionMap GuidedStep, GeoPoint, SectionArticle
Guide InstanceConfiguration

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éshey_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émentCanonVersion 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 ResourceDTOExportConfigurationDTO → 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 §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.ImageUrlImageResourceId, 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

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.ImageUrlImageResourceId 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), 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 — fait : ResourceDTO.narratorName / narratorPortraitUrl remplis pour un audio narré (depuis sa provenance, donc la voix réellement entendue) sur le détail de ressource et l'export ; portrait + nom à côté du lecteur sur web (article, parcours liste et carte), mymuseum-visitapp (article en ligne, parcours contenu et carte) et tablet-app (article). ⚠️ Bug trouvé au passage : les deux apps visiteur ne lisaient l'audio d'une étape que si audioIds portait une URL, et le web lisait l'article de même — un audio rangé par identifiant n'avait jamais de lecteur. Résolu par l'identifiant de ressource. Hors ligne : pas de portrait. Plan de test : ../test-plan-studio-narration.md.

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, 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 §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

  1. create et _replaceFile (resources_screen.dart) : upload-urlPUT 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.
  2. 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 = AssetValidationResourceIngestionService (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.ImageUrlImageResourceId : 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.