From d70d851b6f95b52a52cafffc4f7ba225a9f30924 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Sun, 13 Sep 2026 17:51:23 +0200 Subject: [PATCH] Plan Studio consolide, lot 0 livre studio-plan.md : section 8 executable pour les lots 0 a 4 (prerequis, etapes, verifications, gate) et section 9 catalogue v0 (styles, preambule, gabarits, calibrage). Decisions 14 a 21 : upload par URL signee et ingestion serveur, MVP texte + image source sur objets et lieux, prix differes et Grant manuel, une recharge prolonge tout le solde, plafonds d'upload par type, catalogue calibre fin de lot 3, lignage en colonnes, contrat fournisseur multi-fichier. Corrige les contradictions laissees par les revisions : Kind retire de Persona, talking head, API credits, webhook fal.ai signe en ED25519 et non en HMAC, enum ResourceType deja etendu. Les noms suivent le code du lot 0 (ResourceIngestionService, GetInfoAsync, ReadAllAsync). immersif-frontiere-plan.md : decisions 1, 4 et 6 marquees reportees. Kanban : cartes 280 et 300 a jour, carte 305 sur le watermark inactif depuis l'upload direct. Co-Authored-By: Claude Opus 5 (1M context) --- kanban.html | 137 +++-- ...-studio-generation-d-images-sous-identi.md | 5 +- ...-de-stockage-serveur-le-backend-ne-sait.md | 15 +- ...termark-des-images-est-inactif-depuis-l.md | 18 + v2/immersif-frontiere-plan.md | 43 +- v2/studio-plan.md | 577 +++++++++++++++--- 6 files changed, 663 insertions(+), 132 deletions(-) create mode 100644 kanban/cards/5-planifie/305-le-watermark-des-images-est-inactif-depuis-l.md diff --git a/kanban.html b/kanban.html index a9d1bae..9f7596d 100644 --- a/kanban.html +++ b/kanban.html @@ -462,9 +462,9 @@
1Migration v3
2Bugs ouverts
12À tester
-
39Planifié
+
38Planifié
9Bascule prod
-
63Fait récemment
+
66Fait récemment
@@ -508,9 +508,15 @@ → 200, avec le contenu complet et ses traductions

La même requête avec X-Api-Key renvoie le même 200. Cause exacte, vérifiée le 08/09 : Get (ConfigurationController.cs:52) et GetDetailAsync (:117) portent un [AllowAnonymous] explicite. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (client.dart:54), et le mécanisme existe et fonctionne : ApiKeyAuthenticationHandler + la policy AppReadAccess, déjà utilisée par byPin (:83) et export (:383) du même contrôleur.

Ce que ça expose : le contenu éditorial de n'importe quelle instance, à qui connaît un instanceId — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.

-

⚠️ À vérifier avant de conclure : le périmètre exact. J'ai testé /api/Configuration ; il faut passer en revue les autres routes que consomment les apps visiteur (/api/Section/configuration/{id}, /api/Resource/{id}, /api/SectionMap, /api/SectionQuiz) avant de savoir si le trou est ponctuel ou général.

-

C'est le problème symétrique du 403 sur GET /api/Instance/{id}, corrigé le 08/09 (voir la carte close) : la même app est trop bloquée sur une route et pas bloquée du tout sur les autres. Les deux se traitent ensemble, en décidant ce que X-Api-Key est censé garder.

- conversation 08/09 — vérification de la préprod depuis internet + +

Audit complet fait le 12/0940 routes anonymes recensées, dont 2 seulement contrôlent la clé (Configuration/{id}/export et Instance/{id}, tous deux corrigés en septembre). Le trou est donc général, pas ponctuel : 24 routes de contenu sont ouvertes — Configuration (2), Section (4), Resource (2), SectionMap (3), SectionEvent (3), SectionAgenda (2), SectionParcours (2), SectionQuiz (1), ApplicationInstance (2).

+ +

🔴 Trouvaille plus grave que la carte, corrigée le 12/09. GET /api/Instance/slug/{slug} était anonyme et sans filtrage : il rendait le pinCode — celui qui ouvre l'appairage des tablettes et des casques — plus l'adresse de facturation, le numéro de TVA, les quotas et le plan du client. Et le slug n'est pas un secret : c'est l'URL du site visiteur (app.myinfomate.be/{slug}). La fonction de filtrage StripCommercialFields existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur slug et sur byPin, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : publicApiKey, sans laquelle visitapp-web ne peut pas s'amorcer, et isTrialActive, qui sert au filigrane d'essai affiché au visiteur.

+ +

⚠️ Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas. La clé s'obtient anonymement : par le slug (qui est dans l'URL) ou par app-key avec le pincode. Après fermeture, le contenu ne sera donc pas confidentiel : il sera lisible par qui lit une URL, au lieu de qui devine un instanceId. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et révocable, et un seul chemin d'entrée au lieu de vingt-quatre. Rendre le contenu réellement privé serait un autre chantier, avec une autre décision produit.

+ +

Reste à faire : fermer les 24 routes de contenu. Deux obstacles connus. Un : [Authorize] de classe et d'action se combinent — c'est pour ça qu'export passe par [AllowAnonymous] + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. Deux : deux routes doivent rester anonymes et c'est écrit dans le code de visitapp-webinstance/slug/{slug} (l'amorce, qui distribue la clé) et ApplicationInstance?instanceId= (la page /download, où le visiteur arrive d'un QR imprimé sans slug ni clé).

+ conversation 08/09 — vérification de la préprod depuis internet · audit complet 12/09
@@ -686,7 +692,7 @@
-

Planifié

39
+

Planifié

38
@@ -945,13 +951,6 @@ todo-features.md — SectionForm
-
-
produitV2
-

Ressource 360° — images panoramiques

-

Une valeur d'enum (Panorama360) et un branchement dans l'affichage des ressources : s'affiche partout où une ressource s'affiche, sans toucher aux sections. Reporté en V2 le 07/08 — le plus petit des trois, à reprendre en premier.

- todo-features.md — Ressource 360° -
-
produitV2

AR image tracking (Mind AR)

@@ -968,8 +967,9 @@
-
Studio IAV2 · nouveau 01/09
+
Studio IAV2 · consolidé 13/09, lots 0-4 prêts à coder

Module Studio — génération d'images sous identité visuelle

+

Plan consolidé le 13/09. Le §8 donne, pour les lots 0 à 4, les prérequis, les étapes, les vérifications et le gate. Le §9 contient un catalogue v0 (8 styles, règle de préambule, 3 gabarits) à calibrer en fin de lot 3. Six décisions ont été prises : upload par URL signée + ingestion serveur (bucket fermé aux clients, gros fichiers hors VPS), MVP limité à « depuis le texte » et « depuis une image » (objets et lieux), prix différés avec Grant manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en ED25519, pas en HMAC.

Le produit n'est pas l'accès aux modèles, c'est la cohérence. N'importe qui peut générer une image ailleurs. La valeur : qu'un conservateur qui ne sait pas prompter obtienne, en trois champs, une image raccord avec les quarante autres du même parcours. Une VisualIdentity par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + surcharge optionnelle par configuration — le parcours Halloween a sa propre identité, exactement comme Configuration porte déjà ses PrimaryColor/Languages. Injectée côté serveur dans chaque prompt, jamais retapée. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.

Prérequis dur, carte séparée : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.

⚠️ Quatre pièges déjà tranchés. Une URL Firebase à jeton est publique — un brouillon dans pictures/ serait servable au visiteur : préfixe studio-drafts/ + proxy authentifié. Le quota IA existant ne tient pas sur des jobs parallèles (CheckQuota avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un CreditLedger. Le plafond dur n'arrête pas le stagiaire — c'est un plafond par utilisateur et par jour qu'il faut, pas un plafond organisation. Et GuidedStep.ImageUrl est une URL, pas un ResourceId : les images générées de l'escape game ne partiraient pas offline (GuidedStep.cs:74) — migration à faire dans le lot MVP.

@@ -978,7 +978,7 @@

👤 Trois entrées distinctes dans le panneau de génération, à ne pas confondre (précisé le 02/09) : l'identité visuelle donne le style du projet, l'image source donne cet objet-ci (la vraie pièce de la collection, avec un curseur de fidélité : rien / le sujet / sujet + pose / cadrage exact), et le personnage donne cette figure récurrente — « c'est Léon qui est dans le donjon ». Le canon part en référence et ses paramètres (époque, costume, âge) sont injectés en texte : une image de référence seule ne porte pas « capote d'officier ».

⚠️ Deux personnages dans une image coûtent la cohérence du style. Le plafond de références est celui du modèle (10 pour FLUX.2) : un personnage à 4 vues + 4 références d'identité + 1 source = 9/10, mais deux personnages font tronquer l'identité — donc le style dérive précisément sur l'image la plus ambitieuse. Priorité serveur au canon, troncature de l'identité ensuite, jauge visible dans l'UI et avertissement dès le deuxième. Un personnage par image, et on compose la scène autour de lui.

🖼️ Attention aux personnes identifiables. « Modifier une image » sur la photo d'une personne réelle, chez des institutions publiques, c'est du traitement de ressemblance : 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.

- v2/studio-plan.md — lots 0 à 10 + v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0)
@@ -1032,13 +1032,30 @@ DOCS/claude design/refonte-configuration-sections.html
-
-
manager-serviceStudio IAPrérequis dur
-

Socle de stockage serveur — le backend ne sait pas écrire dans le bucket

-

IResourceBlobService n'expose que DeleteAsync : tout l'upload est fait par le navigateur en direct (resources_screen.dart:247-256). Or le Studio produit ses images côté serveur, et le TTS pré-généré aussi. Il faut UploadAsync, CopyAsync, ProbeSizeAsync — le credential est déjà chargé pour FCM, seul Firebase:StorageBucket est vide en config (carte dédiée en colonne Bascule prod).

-

⚠️ ImageHelper est du code mort : ResourceController.Upload (base64 + resize + watermark) s'appuie sur System.Drawing.Common, qui ne tourne pas sur l'image aspnet:8.0 Linux. À réécrire en ImageSharp — c'est aussi ce qui portera la compression serveur 2560 px / q82 et l'option de gravure du watermark.

-

Un lot, deux modules servis. Le TTS pré-généré attend exactement la même chose (tts-pregenerated-plan.md : « upload serveur via Firebase Admin SDK »). Le faire une fois, proprement, débloque les deux. À caser avec l'ajout de LB (luxembourgeois) aux langues supportées — absent des 4 repos, zéro occurrence, et bloquant pour le projet luxembourgeois en cours indépendamment du Studio.

- v2/studio-plan.md — lot 0 · v2/media-storage-plan.md +
+
manager-servicemanager-appStudio IAsécuritéPrérequis dur
+

Socle de stockage — URL d'envoi signée et ingestion serveur

+

IResourceBlobService n'expose que DeleteAsync : tout l'upload est fait par le navigateur en direct, à deux endroits de resources_screen.dart. Or le Studio et le TTS pré-généré produiront côté serveur.

+

⚠️ Trou de sécurité actuel, indépendant du Studio. manager-app n'a pas de Firebase Auth (firebase_storage sans firebase_auth) et écrit pourtant dans le bucket : les règles Storage sont très probablement ouvertes en écriture. Aucun storage.rules dans le repo — à relever dans la console.

+

Tranché le 13/09 : URL signée + ingestion. L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans incoming/, puis POST /ingest : un ResourceIngestionService unique — le même pour le Studio et le TTS — mesure la taille réelle, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous pictures/. Les vidéos, 360 et GLB sont copiés côté Google, jamais lus par le VPS. Les écritures clientes sont ensuite fermées dans les règles.

+

Infra : CORS du bucket pour le PUT, règle de cycle de vie à 1 jour sur incoming/, et fermeture des règles après le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route upload morte et d'ImageHelper, Firebase:StorageBucket renseigné, et LB (luxembourgeois).

+ v2/studio-plan.md — §8 lot 0, décisions 14, 18, 20 +
+ +
+
manager-serviceMédiathèqueIsImageWatermark activé ne filigrane rien
+

Le watermark des images est inactif depuis l'upload direct

+

Constat du 13/09. Le seul code de watermark vivait dans ResourceController.Upload (base64, System.Drawing) : il écrivait le texte « fortsaintheribert.be » en dur, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, aucune image n'est filigranée, même sur une instance où Instance.IsImageWatermark est activé.

+

Le lot 0 du Studio supprime ce code mort et ne réimplémente rien : pas de régression par rapport à la prod actuelle, mais le drapeau ment.

+

À trancher avant de le refaire, dans le ResourceIngestionService du lot 0 (le seul endroit qui voit passer toutes les images) :

+
    +
  • le Fort le veut-il encore ?
  • +
  • texte ou logo, et par instance au lieu d'un nom de domaine en dur ;
  • +
  • une police embarquée dans le repo — Arial n'existe pas dans le conteneur aspnet:8.0 Linux — via SixLabors.ImageSharp.Drawing ;
  • +
  • jamais sur une Image360, comme la compression.
  • +
+

Distinct de la gravure « générée par IA » du Studio (Instance.IsAiWatermarkBurned, décision 6), qui réutilisera le même chemin.

+ conversation 13/09 — lot 0 du Studio (studio-plan.md §8)
@@ -1057,15 +1074,6 @@ v2/studio-plan.md §3.8 — lots 7 et 8
-
-
XRV2 · nouveau 31/08
-

Back-office XR — Device.AppType + onglet flotte de casques

-

⚠️ « Zéro ligne de code » était faux — relevé dans le code le 31/08. Le canal VR est déjà dans le modèle : AppType.VR (valeur 3) côté C# et dans manager_api_new, Instance.IsVR en base depuis la migration de juillet 2025, sous-menu « VR » branché dans main_screen.dart:65, AppConfigurationLink.DeviceId (le mécanisme « une config par appareil » du kiosk), filtre stats et i18n statsChannelVR prêts.

-

Deux trous seulement. main_screen.dart:704 est littéralement un Text("TODO vr") ; et DeviceController.Create est hardcodé sur AppType.Tablet (DeviceController.cs:155), donc un casque enregistré aujourd'hui atterrirait dans l'onglet Kiosk. Il faut un Device.AppType (défaut Tablet, l'existant ne bouge pas) et ApiKeyAppType.VrApp en fin d'enum — il est persisté en int.

-

L'écran est un clone de Kiosk_devices/ (4 fichiers, 769 l.) : grille de casques, pincode d'appairage, assignation d'une configuration par appareil, plus batterie / AppVersion / LastSeen — colonnes déjà en base, jamais affichées pour le kiosk. Estimé 8-12 j au total (backend 2-3 j, écran 4-5 j, contrat de contenu 1-2 j), et ce lot se tient debout tout seul : il rend le canal administrable et démontrable avant tout engagement sur Unity.

- v2/vr-quest-unity-plan.md §1, §2 (lots V-1 à V-3) -
-
studiovideoIAquelques euros, une soirée — débloque une décision produit

Test « faire vivre un lieu » — 8 s au Fourneau

@@ -1078,14 +1086,33 @@
-
XRV2 · subventionné · go/no-go
+
XRV2 · POC écrit en entier, rien n'a jamais tourné

Meta Quest — app Unity + Meta XR SDK

-

Stack tranchée le 31/08 : Unity 6 LTS + OpenXR + Meta XR feature group, pas React Native. Le contenu se récupère par GET /api/configuration/{id}/export — un seul appel qui renvoie configuration + sections + ressources, donc pas de DTO C# à réécrire endpoint par endpoint. POC 3-4 semaines, couverture complète 6-10 semaines, + 1-2 sem. de supervision de flotte (le publish MQTT serveur existe déjà, DeviceController.cs:303).

-

⚠️ Le risque est produit, pas technique. Le CMS est 2D : un Article sur un panneau flottant est moins bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent — et la ressource 360° est un prérequis dur (carte dédiée dans cette colonne). Go/no-go conditionné à un client pilote disposant déjà de vidéos 360° ; sans lui, le POC démontrera une régression.

-

Unity n'est pas une nécessité technique (tranché le 31/08) : le mode kiosk vient de la gestion d'appareil, pas du moteur — un MDM verrouille n'importe quelle app installée, y compris un navigateur épinglé. Unity gagne sur l'exploitation sans surveillance : cache offline multi-Go, décodage 8K, et surtout un build figé là où le navigateur du casque s'auto-met à jour et peut casser la borne. Contre-argument sérieux à garder : visitapp-web rend déjà les 13 types en React, donc une couche WebXR attaquerait le risque « quatrième front à maintenir ». Retenu : WebXR pour la maquette, Unity pour la borne — à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».

-

⚠️ MDM rétrogradé le 31/08 : inutile à 1-3 casques (sideload + mode appareil suffisent), ne redevient un sujet qu'au-delà d'une dizaine.

-

⚠️ Les Ray-Ban ne sont plus dans cette carte : elles sont passées en V1 le 12/08, et leur volet stats est livré depuis le 13/08 (voir « Fait récemment »).

- v2/vr-quest-unity-plan.md §2 (lots V-4, V-5), §4, §5 · roadmap.md — section XR +

Stack tranchée le 31/08 : Unity 6 LTS + OpenXR + Meta XR feature group. Le contenu se récupère par GET /api/configuration/{id}/export — un seul appel qui renvoie configuration, sections et ressources, donc aucun DTO C# à réécrire endpoint par endpoint. Unity n'est pas une nécessité technique : le mode kiosk vient de la gestion d'appareil, pas du moteur. Unity gagne sur l'exploitation sans surveillance — cache offline multi-Go, décodage 8K, et un build figé là où le navigateur du casque s'auto-mettrait à jour et pourrait casser la borne un mardi matin. Retenu : WebXR pour la maquette, Unity pour la borne, à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».

+ +

Le POC est écrit en entier — E0 à E10, entre le 11 et le 12/09. Unity 6000.0.83f1, projet URP dans vr-app/, APK sideloadé sur un Quest 2 (Horizon OS v207). Mesuré sur casque : un décor glTF de 50 Mo charge en 1,4 s et tient 72 FPS, GPU au maximum.

+ + + + + + + + + + +
ItemCe qui est écrit
E2-E3Appairage par code PIN (PairingService) et lecture de l'export. ⚠️ Un 404 sur POST /api/device ne veut pas dire « introuvable » : il n'y a pas d'ApplicationInstance VR sur l'instance.
E4Cache d'abord : l'app démarre sur le disque, se rafraîchit en fond, et l'échec du rafraîchissement ne se voit pas. Écriture atomique — une borne se débranche le soir. C'est l'argument n°1 d'Unity contre le web.
E5Menu flottant en arc à 2,2 m, qui ne suit pas la tête. Trois moyens de viser : manette (gâchette), main (pincement), tête (1,2 s, contre le « Midas touch »). ⚠️ « À la tête », pas « aux yeux » — le Quest 2 n'a pas d'eye tracking. ⚠️ La dépendance E5 → E1 du plan était fausse : OVRInput et OVRHand viennent du Core SDK, donc E1 sort du chemin critique.
E6Image et vidéo 360° en skybox. Le retour au menu ne reste pas planté dans le décor : 4 s, puis il s'efface et revient quand on baisse les yeux — toute la valeur d'une 360 est d'y être. ⚠️ Deux pièges invisibles dans l'éditeur : Skybox/Panoramic doit être dans Always Included Shaders (sinon ciel magenta sur le casque seul), et une équirectangulaire décodée pèse 134 Mo — compressée en ASTC et libérée à la sortie, sinon l'app meurt après quelques 360.
E8Slider, Map, Parcours et Event, dans une même vue paginée. ⚠️ Deux écarts assumés : la Map est une liste de POI, pas la maquette 3D promise (elle demande SectionModel3D) ; le Parcours est rendu comme une Map, pour la même raison.
E9Télémétrie « tire et oublie ». ⚠️ Route /api/stats/event, et appType y part en chaîne parsée par nom : une faute de frappe retombe sur Mobile sans erreur.
E10Fin de visite à la repose du casque ou après 90 s, menu recentré devant le visiteur suivant, nouvelle session de stats. ⚠️ Le verrouillage de l'app sur le casque n'est pas du code : c'est le mode appareil de Meta.
+ +

⚠️ Rien de E2 à E10 n'a jamais tourné — ni contre un vrai serveur, ni sur le casque. Le code compile, c'est tout ce qu'on sait. Plan de test §25, 9 blocs, écrit pour ça ; son §25.7 (déclarer une 360 dans le manager) se joue sans casque.

+ +

⚠️ Le risque est produit, pas technique, et il est intact. Le CMS est 2D : un Article sur un panneau flottant est moins bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent. Go/no-go conditionné à un client pilote disposant déjà de vidéos 360° — rien de ce qui a été codé ne répond à cette question-là.

+ +

🟡 XR-5, la supervision de flotte : l'essentiel est écrit (12/09). Un battement HTTP toutes les 3 min remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR, qui affichaient « — » faute d'alimentation — Update n'écrivait ni AppVersion ni LastSeen. Pas de MQTT : à 1-3 casques il coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour pousser vers le casque, pas pour remonter. Reste : le client MQTT le jour où l'on voudra recharger une configuration à distance, et une alerte quand un casque ne donne plus signe.

+ +

🟡 E7, la maquette 3D, est écrit aussi (12/09). SectionModel3D est un vrai type de section — une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres — mais il réutilise le GeoPoint, qui portait déjà deux rattachements : les points gardent titre, description, audio et multilingue. ✅ L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial du lot, n'était pas à écrire : vr-app/viewer sait déjà le faire et son protocole postMessage existait déjà, pensé pour ce cas. Le manager le monte en iframe, comme il le fait déjà trois fois ailleurs. Côté casque, rien de dessiné non plus : on construit un manifeste et on le passe au moteur de la piste S. ⚠️ Le viewer n'a jamais tourné, et doit être servi sous le même domaine que le manager.

+ +

Reste au lot : E11 (distribution : Horizon Store ou canal privé, à revérifier avant toute offre). ⚠️ MDM rétrogradé le 31/08 : inutile à 1-3 casques, sideload + mode appareil suffisent.

+ v2/vr-quest-unity-plan.md §2 (lots XR-4, XR-5), §4, §5 · roadmap.md — section XR
@@ -1543,11 +1570,45 @@ ⚠️ Deux pièges tranchés en écrivant : la montée v4 laisse les dates existantes à NULL et les considère à jour (backfiller à zéro aurait fait re-télécharger toutes les visites de tous les visiteurs sur leur réseau mobile) ; et la boucle en masse héritée de D1 écrasait la date que la boucle de téléchargement venait d'écrire — DatabaseHelper.insert fait un UPDATE de la ligne entière quand l'id existe.
+
+ Back-office XR — le canal VR est administrable +

Clos le 12/09. Le chantier tenait en trois lots, et les trois sont retombés : XR-1 le 11/09 (Device.AppType, migration 20260911135527_AddAppTypeToDevice, DeviceController.Create qui résout l'ApplicationInstance sur appType, ApiKeyAppType.VrApp en fin d'enum, 224 tests au vert), XR-3 qui était déjà fait sans que le plan le sache (export ouvert aux apps depuis 9cc45c5), et XR-2 le 12/09.

+

L'écran XR est une coquille à deux sous-onglets : « Configuration », qui réutilise AppConfigurationLinkScreen tel quel — il est déjà générique sur l'appType, zéro code neuf —, et « Casques », la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL.

+

⚠️ Deux affirmations du plan étaient fausses, relevées dans le code. La grille kiosk ne liste pas des Device mais des AppConfigurationLink : le filtre par canal était déjà implicite, et le paramètre appType de /api/device ajouté par XR-1 ne sert pas à cet écran. Et batterie / version / dernier vu ne sont pas dans DeviceDTO, seulement dans DeviceDetailDTO — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités.

+

Reste au plan deux puces purement documentaires de XR-3 : figer le JSON d'export comme contrat public versionné, et décider si l'export doit rendre toutes les langues d'un coup pour un casque en borne. Le prochain vrai coût est XR-4, l'app Unity — carte séparée.

+
+
Trois plans écrits, deux écrans maquettés Médias & stockage, visite hors ligne, écran Guide IA, écran Statistiques. Le découpage V1/V2 est tranché : le RAG sur contenu CMS part en V1, l'ingestion documentaire en V2. Les deux écrans maquettés sont désormais implémentés.
+
+ Ressource 360° — et la lecture immersive qu'elle débloque +

Clos le 12/09. Trois valeurs ajoutées en fin de ResourceTypeImage360 (11), Video360 (12), Model3D (13) — sans migration : la colonne est déjà integer, une valeur d'enum n'y change rien. Un test les verrouille une par une, parce que le commentaire du code avertissait du risque (« les PDF deviendraient des JSON ») sans que rien ne l'empêche.

+

La carte disait « une valeur d'enum et un branchement dans l'affichage ». Il manquait les deux morceaux qui comptent.

+

Un : comment on déclare une 360. Aucune extension ne le dit — un panorama est un .jpg comme un autre. La pastille de type du sélecteur de médias devient donc cliquable : elle bascule Image ↔ Image 360°, Vidéo ↔ Vidéo 360°, et se colore quand c'est immersif. Le .glb, lui, se déduit seul : un GLB n'est jamais autre chose qu'un modèle 3D.

+

Deux, et c'est le vrai piège : la compression. ImageCompressor ramène toute image à 2560 px de côté long. Une équirectangulaire de 8192×4096 y perd les trois quarts de sa définition — invisible sur un écran, une bouillie dans un casque où elle couvre tout le champ de vision. Les trois types en sont exclus, sur les deux chemins d'upload, qui avaient déjà divergé deux fois par le passé.

+

Bonne surprise : le prérequis « le backend ne sait pas écrire dans le bucket », qui bloque le Studio et le TTS, ne s'applique pas icimanager-app pousse directement dans Firebase Storage puis enregistre l'URL. L'ancienne route serveur /upload plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.

+

➡️ Ce que ça débloque : E6 du lot XR-4, la lecture 360° dans le casque, écrite dans la foulée (SkyboxView, image et vidéo dans le même shader panoramique). C'était le dernier prérequis du POC VR. ⚠️ Skybox/Panoramic doit être dans Always Included Shaders, sinon le ciel sort magenta sur le casque et correct dans l'éditeur. Plan de test §25.7 — sa première moitié se joue sans casque.

+

Non fait, et assumé : l'exploitation du Model3D. La valeur existe, mais un modèle 3D demande encore un type de section dédié, une position 3D sur les points d'intérêt et un éditeur de placement — c'est E7, et le §9 du plan VR le décrit.

+
+ +
+ L'appairage d'une nouvelle tablette répondait 403 depuis mars +

Trouvé et corrigé le 12/09, en écrivant l'appairage du casque : il butait sur le même mur.

+

DeviceController porte [Authorize(InstanceAdmin)] sur la classe, et Create n'avait aucune exception. Or une clé d'API ne porte que AppRead et Viewer (ApiKeyAuthenticationHandler), et tablet-app ne s'authentifie jamais autrement — aucun appel d'authentification dans tout le repo. POST /api/device répondait donc 403 à toute tablette.

+

Depuis quand : commit a452f4a du 13/03/2026, dont le message dit lui-même « need to be tested ». Pourquoi personne ne l'a vu : une tablette déjà appairée ne rappelle jamais Create. Le parc existant continuait de fonctionner ; seul un nouvel appareil échouait — c'est-à-dire exactement ce qu'on ne fait pas tous les jours.

+

Correctif : Create passe en [AllowAnonymous] + [RequireAppKey], le filtre posé le même jour pour les routes de contenu. Le cloisonnement, lui, était déjà écrit juste en dessous — une clé ne peut créer un appareil que dans son instance.

+ 🔴 Et ce n'était pas que l'appairage. En auditant les clients le 12/09, deux autres maillons du même mur :

+
    +
  • GET /api/Device/{id}/detail était fermé aux clés lui aussi — or c'est le premier appel d'une tablette qui démarre (tablet-app/lib/main.dart:46) : celui qui lui dit quelle configuration afficher. Une tablette déjà appairée ne retrouvait donc plus son contenu au redémarrage. Ouvert de la même façon.
  • +
  • tablet-app/lib/main.dart:38 reconstruisait son client sans cléClient(host) au lieu de Client(host, apiKey: …) — alors que la clé est persistée en base locale (TabletAppContext.toMap). Une ligne, et tous les appels d'après partaient anonymes.
  • +
+

Les deux se tenaient : la route refusait les clés, et l'app n'en envoyait pas. Corrigés ensemble.

+

⚠️ À vérifier sur le terrain : appairer une vraie nouvelle tablette, et redémarrer une tablette déjà appairée. Le correctif est couvert par les tests, mais les tests appellent le contrôleur directement — aucun filtre d'autorisation ne s'y exécute, donc ils ne prouvent rien sur l'accès lui-même.

+
+
diff --git a/kanban/cards/5-planifie/280-module-studio-generation-d-images-sous-identi.md b/kanban/cards/5-planifie/280-module-studio-generation-d-images-sous-identi.md index 9c612d3..22e76f5 100644 --- a/kanban/cards/5-planifie/280-module-studio-generation-d-images-sous-identi.md +++ b/kanban/cards/5-planifie/280-module-studio-generation-d-images-sous-identi.md @@ -3,9 +3,10 @@ title: Module Studio — génération d'images sous identité visuelle area: backend manager horizon: v2 tags: Studio IA -flag: warn | V2 · nouveau 01/09 -src: v2/studio-plan.md — lots 0 à 10 +flag: good | V2 · consolidé 13/09, lots 0-4 prêts à coder +src: v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0) --- +

Plan consolidé le 13/09. Le §8 donne, pour les lots 0 à 4, les prérequis, les étapes, les vérifications et le gate. Le §9 contient un catalogue v0 (8 styles, règle de préambule, 3 gabarits) à calibrer en fin de lot 3. Six décisions ont été prises : upload par URL signée + ingestion serveur (bucket fermé aux clients, gros fichiers hors VPS), MVP limité à « depuis le texte » et « depuis une image » (objets et lieux), prix différés avec Grant manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en ED25519, pas en HMAC.

Le produit n'est pas l'accès aux modèles, c'est la cohérence. N'importe qui peut générer une image ailleurs. La valeur : qu'un conservateur qui ne sait pas prompter obtienne, en trois champs, une image raccord avec les quarante autres du même parcours. Une VisualIdentity par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + surcharge optionnelle par configuration — le parcours Halloween a sa propre identité, exactement comme Configuration porte déjà ses PrimaryColor/Languages. Injectée côté serveur dans chaque prompt, jamais retapée. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.

Prérequis dur, carte séparée : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.

⚠️ Quatre pièges déjà tranchés. Une URL Firebase à jeton est publique — un brouillon dans pictures/ serait servable au visiteur : préfixe studio-drafts/ + proxy authentifié. Le quota IA existant ne tient pas sur des jobs parallèles (CheckQuota avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un CreditLedger. Le plafond dur n'arrête pas le stagiaire — c'est un plafond par utilisateur et par jour qu'il faut, pas un plafond organisation. Et GuidedStep.ImageUrl est une URL, pas un ResourceId : les images générées de l'escape game ne partiraient pas offline (GuidedStep.cs:74) — migration à faire dans le lot MVP.

diff --git a/kanban/cards/5-planifie/300-socle-de-stockage-serveur-le-backend-ne-sait.md b/kanban/cards/5-planifie/300-socle-de-stockage-serveur-le-backend-ne-sait.md index 442fecd..44a5089 100644 --- a/kanban/cards/5-planifie/300-socle-de-stockage-serveur-le-backend-ne-sait.md +++ b/kanban/cards/5-planifie/300-socle-de-stockage-serveur-le-backend-ne-sait.md @@ -1,11 +1,12 @@ --- -title: Socle de stockage serveur — le backend ne sait pas écrire dans le bucket -area: backend +title: Socle de stockage — URL d'envoi signée et ingestion serveur +area: backend manager horizon: v2 -tags: manager-service, Studio IA +tags: manager-service, manager-app, Studio IA, sécurité flag: critical | Prérequis dur -src: v2/studio-plan.md — lot 0 · v2/media-storage-plan.md +src: v2/studio-plan.md — §8 lot 0, décisions 14, 18, 20 --- -

IResourceBlobService n'expose que DeleteAsync : tout l'upload est fait par le navigateur en direct (resources_screen.dart:247-256). Or le Studio produit ses images côté serveur, et le TTS pré-généré aussi. Il faut UploadAsync, CopyAsync, ProbeSizeAsync — le credential est déjà chargé pour FCM, seul Firebase:StorageBucket est vide en config (carte dédiée en colonne Bascule prod).

-

⚠️ ImageHelper est du code mort : ResourceController.Upload (base64 + resize + watermark) s'appuie sur System.Drawing.Common, qui ne tourne pas sur l'image aspnet:8.0 Linux. À réécrire en ImageSharp — c'est aussi ce qui portera la compression serveur 2560 px / q82 et l'option de gravure du watermark.

-

Un lot, deux modules servis. Le TTS pré-généré attend exactement la même chose (tts-pregenerated-plan.md : « upload serveur via Firebase Admin SDK »). Le faire une fois, proprement, débloque les deux. À caser avec l'ajout de LB (luxembourgeois) aux langues supportées — absent des 4 repos, zéro occurrence, et bloquant pour le projet luxembourgeois en cours indépendamment du Studio.

+

IResourceBlobService n'expose que DeleteAsync : tout l'upload est fait par le navigateur en direct, à deux endroits de resources_screen.dart. Or le Studio et le TTS pré-généré produiront côté serveur.

+

⚠️ Trou de sécurité actuel, indépendant du Studio. manager-app n'a pas de Firebase Auth (firebase_storage sans firebase_auth) et écrit pourtant dans le bucket : les règles Storage sont très probablement ouvertes en écriture. Aucun storage.rules dans le repo — à relever dans la console.

+

Tranché le 13/09 : URL signée + ingestion. L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans incoming/, puis POST /ingest : un ResourceIngestionService unique — le même pour le Studio et le TTS — mesure la taille réelle, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous pictures/. Les vidéos, 360 et GLB sont copiés côté Google, jamais lus par le VPS. Les écritures clientes sont ensuite fermées dans les règles.

+

Infra : CORS du bucket pour le PUT, règle de cycle de vie à 1 jour sur incoming/, et fermeture des règles après le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route upload morte et d'ImageHelper, Firebase:StorageBucket renseigné, et LB (luxembourgeois).

diff --git a/kanban/cards/5-planifie/305-le-watermark-des-images-est-inactif-depuis-l.md b/kanban/cards/5-planifie/305-le-watermark-des-images-est-inactif-depuis-l.md new file mode 100644 index 0000000..11b7636 --- /dev/null +++ b/kanban/cards/5-planifie/305-le-watermark-des-images-est-inactif-depuis-l.md @@ -0,0 +1,18 @@ +--- +title: Le watermark des images est inactif depuis l'upload direct +area: backend manager +horizon: v2 +tags: manager-service, Médiathèque +flag: warn | IsImageWatermark activé ne filigrane rien +src: conversation 13/09 — lot 0 du Studio (studio-plan.md §8) +--- +

Constat du 13/09. Le seul code de watermark vivait dans ResourceController.Upload (base64, System.Drawing) : il écrivait le texte « fortsaintheribert.be » en dur, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, aucune image n'est filigranée, même sur une instance où Instance.IsImageWatermark est activé.

+

Le lot 0 du Studio supprime ce code mort et ne réimplémente rien : pas de régression par rapport à la prod actuelle, mais le drapeau ment.

+

À trancher avant de le refaire, dans le ResourceIngestionService du lot 0 (le seul endroit qui voit passer toutes les images) :

+ +

Distinct de la gravure « générée par IA » du Studio (Instance.IsAiWatermarkBurned, décision 6), qui réutilisera le même chemin.

diff --git a/v2/immersif-frontiere-plan.md b/v2/immersif-frontiere-plan.md index 3390cf5..367f94d 100644 --- a/v2/immersif-frontiere-plan.md +++ b/v2/immersif-frontiere-plan.md @@ -258,12 +258,47 @@ Chaque canal déclare ce qu'il sait rendre ; le fallback est `Configuration.Imag --- +## 4bis. Viewer de scène et viewer d'asset — un socle, deux modes + +> **Arrêté le 2026-09-11.** La question « c'est le même viewer ? » se pose naturellement, et la +> réponse détermine un chiffrage à trois implémentations près. + +**Ce qui les sépare est la caméra, et c'est une opposition franche.** + +| | **Viewer d'asset** (l'épée du roi) | **Viewer de scène** (le monde de référence) | +|---|---|---| +| Caméra | **orbitale** — l'objet au centre, on tourne autour, on zoome | **look-around** — on est au centre, on regarde autour | +| Le visiteur | **manipule** l'objet | **est dans** la scène, ne manipule rien | +| Donnée | un GLB léger, objet isolé | un splat / mesh d'environnement, lourd | +| POI | sur la surface, **tournent avec l'objet** | fixes dans l'espace, on les regarde | + +**Ce qu'ils partagent** : le loader GLB, le canvas, le raycast sur POI, et surtout **le même modèle +de POI** — une position locale (x,y,z). Donc **un socle de rendu avec un mode**, pas deux +implémentations. Et **un seul éditeur** dans le manager, avec un mode lui aussi. + +⚠️ **Le point qui compte pour le chiffrage : ils ne vivent pas au même endroit.** + +| | Web (React) | Mobile (Flutter) | VR (Unity) | +|---|---|---|---| +| Viewer d'asset | ✅ | ✅ | ✅ (item **E7**) | +| Viewer de scène | fallback pano | fallback 2D | ✅ (item **E5**) | + +➡️ Le **viewer d'asset est trois implémentations**, le **viewer de scène une seule** plus deux +dégradés. À budgéter dans cet ordre, pas l'inverse : l'intuition pousse à commencer par la scène +parce qu'elle impressionne, alors que c'est l'asset qui coûte trois fois. + +--- + ## 5. Décisions de rétro-compatibilité, du plus cher au moins cher Les points **1 à 5 doivent être écrits dans `studio-plan.md` avant sa première migration.** Les suivants peuvent s'écrire au fil de l'eau. -### 1. Pipeline d'ingestion unique côté serveur +### 1. Pipeline d'ingestion unique côté serveur — ✅ **porté dans `studio-plan.md` le 2026-09-13** + +> Décision 14 : URL d'envoi signée par l'API, puis un `ResourceIngestionService` unique pour tout fichier, +> uploadé ou généré (§8 lot 0). Les gros fichiers — GLB, vidéos 360 — vont directement chez Google +> sans traverser le VPS. **Le trou le plus cher, et il est structurel, pas accidentel.** Aujourd'hui **l'upload est fait par le navigateur en direct** (`resources_screen.dart:247-256`) ; le backend ne sait que supprimer @@ -304,7 +339,7 @@ d'up-axis. Peu de code si pris tôt. Coûteux en confiance client si découvert en prod. -### 4. Lignage en colonnes typées +### 4. Lignage en colonnes typées — ✅ **porté dans `studio-plan.md` le 2026-09-13** (décision 20, lot 0) `Resource.AiProvenance` (jsonb) distingue uploadé (null) de généré (non-null) et liste des `referenceResourceIds[]`. **Mais un tableau dans un jsonb n'est pas un lignage requêtable**, et rien @@ -333,7 +368,7 @@ et le plus bête à subir. ➡️ **Fait** : décision n°3 du tableau §2, bloc `studio-plan.md` sont à jour. Reste ouvert : le mapping Stripe `checkout.session.completed` → `Kind: Grant` pour la recharge. -### 6. Contrat fournisseur : multi-fichier et par kind +### 6. Contrat fournisseur : multi-fichier et par kind — ✅ **porté dans `studio-plan.md` le 2026-09-13** (décision 21, §3.6) `ProviderResult` est pensé mono-fichier image. Une génération 3D retourne GLB + preview + parfois textures séparées ; un monde retourne splat + collider mesh + panorama. @@ -462,6 +497,6 @@ sur l'add-on immersif seul. | Plan | À reprendre | |---|---| | `studio-plan.md` | Le **lot 11 (3D)** passe de quatre lignes à un lot réel : `Scene3D` comme `GenerationKind`, World Labs dans le catalogue, provenance obligatoire sur `Model3D`. Les décisions n°1, 4, 5 et 6 du §5 ci-dessus touchent des lots **antérieurs** (lot 0, lot 1, lot 3) et doivent y être écrites. | -| `vr-quest-unity-plan.md` | Le **§9 (POI sur GLB)** gagne la version de modèle, l'invalidation des POI et la décision « éditeur en iframe three.js ». Le **§6 (pricing)** gagne l'enveloppe de stockage chiffrée. Le §2 lot V-4 gagne `ImmersiveBackground` comme source du fond de scène. | +| `vr-quest-unity-plan.md` | Le **§9 (POI sur GLB)** gagne la version de modèle, l'invalidation des POI et la décision « éditeur en iframe three.js ». Le **§6 (pricing)** gagne l'enveloppe de stockage chiffrée. Le §2 lot XR-4 gagne `ImmersiveBackground` comme source du fond de scène. | | `todo-features.md` | La « Ressource 360° » devient les **trois** valeurs d'enum, en une migration. | | Kanban | Les cartes 250, 280, 300, 320 et 330 restent valides. Ce document est la source à citer en `src:` pour la frontière. | diff --git a/v2/studio-plan.md b/v2/studio-plan.md index 0bdbb4a..915ea5e 100644 --- a/v2/studio-plan.md +++ b/v2/studio-plan.md @@ -1,6 +1,12 @@ # Module Studio — Génération IA d'images, vidéos et 3D -> **Statut** : conception arrêtée le 2026-09-02. Rien de codé. +> **Statut** : conception arrêtée le 2026-09-02, **consolidée le 2026-09-13**. Rien de codé. +> +> **Pour coder, lire d'abord le [§8](#8-exécutable--lots-0-à-4)** : il rassemble, pour les lots 0 à 4, +> les prérequis, les étapes, ce qui les vérifie et la checklist. Les §0-§7 restent la trace de la +> conception et la source des *pourquoi*. La consolidation du 13/09 a reporté ici les décisions de +> [immersif-frontiere-plan.md](immersif-frontiere-plan.md) §5 (n°1, 4, 6), corrigé les contradictions +> laissées par les révisions successives, et tranché six points avec Thomas (décisions 14 à 19). > > **Deux versions, deux documents.** > **V1** — la refonte de la **Médiathèque** : elle a désormais sa **spec autonome et exécutable**, @@ -73,13 +79,26 @@ Exploré : `manager-app`, `manager-service`, `mymuseum-visitapp`, `visitapp-web` ### 1.3 Médiathèque -`Resource { Id, Type(enum int), Label, InstanceId, Url, StoragePath, FileName, SizeBytes, Date*, Ai* }` +`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** (`resources_screen.dart:247-256`). Le backend - ne sait que **supprimer** (`IResourceBlobService.DeleteAsync`). -- `ResourceController.Upload` (base64 + watermark) est **du code mort** : `System.Drawing.Common` - ne tourne pas sur `aspnet:8.0` Linux. +- **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. @@ -124,11 +143,19 @@ Tout sur `Instance`, dupliqué depuis `SubscriptionPlan` : `StorageQuotaBytes`, | 7bis | Guide par visite | **Possible, et c'est le même motif que le reste** : `Configuration.GuidePersonaId ?? Instance.GuidePersonaId`. Un personnage peut donc narrer *et* répondre sur son parcours. **Limite : en mains-libres, c'est le guide de l'instance qui répond** — un wakeword est un modèle embarqué dans le build, et deux guides adressables demanderaient au visiteur de retenir deux noms | | 7ter | Connaissances du guide | `Configuration.GuideKnowledgeScope` = `Instance` \| `Configuration`. Aujourd'hui la recherche vectorielle **ne filtre jamais** par configuration (`ContentEmbedding.ConfigurationId` : « sert à privilégier la visite en cours au classement — jamais à filtrer ») : sur un escape game, le guide peut révéler une solution écrite dans un autre parcours | | 8 | Wakeword | **Propriété du canal, pas du personnage.** Nom du guide libre par défaut ; contraint à un modèle OpenWakeWord disponible **uniquement** si l'instance active un canal mains-libres (lunettes, casque VR). Mobile / web / kiosk : push-to-talk, aucun wakeword | -| 9 | Talking head | **Abandonné.** Son lipsync repose sur `enable_time_pointing` de Google Cloud TTS, or le code tourne sur Gemini TTS qui ne fournit pas de timestamps. Remplacé par le portrait canon statique, puis une boucle vidéo (lot 9) | +| 9 | Talking head | **Abandonné.** Son lipsync repose sur `enable_time_pointing` de Google Cloud TTS, or le code tourne sur Gemini TTS qui ne fournit pas de timestamps. Remplacé par le portrait canon statique, puis une boucle vidéo (lot 10) | | 10 | Narration attribuée | **Retenue en V2** avec le lot TTS : `NarratorPersonaId` sur `GuidedStep` / `GeoPoint` / `SectionArticle` | | 11 | Découpage de version | **La refonte de la Médiathèque passe en V1** (elle corrige des défauts d'aujourd'hui et n'engage aucune clé API). Studio, identité visuelle, personnages, crédits : **V2** | | 12 | Gating | **`StudioEnabled` implique `IsAssistant`** — pas de Studio sans assistant. L'inverse est libre : un client peut avoir l'assistant sans le Studio, et il ne voit alors ni `Guide IA › Personnages › Visage`, ni l'identité visuelle, ni l'onglet Studio | | 13 | Moteur de rendu | **Propriété de l'identité visuelle** (`VisualIdentity.ModelKey`), pas du gabarit. Deux modèles ne rendent pas pareil : en changer au milieu d'un projet casse la promesse. `GenerationModel.RenderFamily` distingue un remplacement bénin d'un changement destructeur | +| 14 | Chemin d'upload — *13/09* | **URL d'envoi signée + ingestion serveur** — *révisé le même jour, remplace « tout par le serveur »*. Le navigateur envoie directement chez Google, dans `incoming/{instanceId}/{resourceId}`, avec une URL V4 signée par l'API (15 min, un seul objet, taille bornée par `x-goog-content-length-range`), puis appelle `POST /ingest` : un `ResourceIngestionService` unique mesure la taille réelle, applique le quota, post-traite et range sous `pictures/`. Le Studio et le TTS passent par le même service. **Les écritures clientes sont fermées dans les règles Storage.** Les gros fichiers (vidéo 360, GLB) ne traversent jamais le VPS. *Pourquoi pas « tout par le serveur » : même fermeture du bucket, mais chaque octet à travers l'API — RAM, timeouts, débit du VPS.* Reporte la décision n°1 d'`immersif-frontiere-plan` §5 | +| 15 | Périmètre du MVP (lot 4) — *13/09* | **Deux points de départ : « depuis le texte » et « depuis une image »** (`sourceFidelity` = `none` \| `subject`), **objets et lieux seulement**. « Modifier une image » (`subjectPose`), `framing` et tout cas de personne réelle : après le gate | +| 16 | Prix des crédits — *13/09* | **Différés.** Le lot 1 livre le mécanisme sans aucun prix ; les crédits sont posés à la main (`Grant` depuis SuperAdmin). Unité, packs et `CreditCost` se fixent **à la fin du lot 3**, sur les coûts fal.ai mesurés. Recharge Stripe self-service : après le gate du lot 4 | +| 17 | Expiration des crédits — *13/09* | **Un solde, une date.** Chaque `Grant` repousse l'expiration de **tout** le solde à `maintenant + 12 mois`. Pas de lots FIFO | +| 18 | Plafonds d'upload — *13/09* | Par type, en configuration : Image 30 Mo · Image 360 80 Mo · Audio 100 Mo · PDF/Word/PowerPoint 50 Mo · Vidéo 500 Mo · Vidéo 360 1 Go · Modèle 3D 200 Mo. Au-delà d'une vidéo de 500 Mo : YouTube/Vimeo, que `SectionVideo` supporte déjà | +| 19 | Contenu éditorial — *13/09* | Un **catalogue v0** (styles, règle d'assemblage, 3 gabarits) est écrit au §9 comme seed ; il est **calibré à la fin du lot 3** par des générations réelles, avant d'ouvrir le lot 4 | +| 20 | Lignage | **`Resource.Origin`** (`Uploaded` \| `Generated` \| `Derived`) et **`Resource.SourceResourceId`** en colonnes typées ; `AiProvenance` jsonb garde le détail. Reporte la décision n°4 d'`immersif-frontiere-plan` §5. Posé au lot 0, puisque l'ingestion l'écrit | +| 21 | Contrat fournisseur | **`ProviderResult` multi-fichier**, délais et relances **par `GenerationKind`**. Le job composite (parent + N enfants) est réservé à la 3D. Reporte la décision n°6 d'`immersif-frontiere-plan` §5 | --- @@ -190,7 +217,7 @@ est donc : > qu'on exporte en CSV sur une plage de dates est une page, pas une popup. > > ⚠️ Ça bute sur un défaut existant : l'entrée **Abonnement n'apparaît que si -> `subscriptionPlanId == "plan-essentiel"`** (`main_screen.dart:547-550`) — une instance Premium ne +> `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`). @@ -225,23 +252,10 @@ indépendants du Studio : réelles, bloc provenance pour les images générées, et une hiérarchie de boutons — Enregistrer en primaire, destructif isolé et **désactivé tant que la ressource est utilisée**. -**« Utilisée dans » est presque gratuit.** `GetReferencedResourceIds(language)` est déjà implémentée -sur les 13 sous-types plus `GuidedPath`/`GuidedStep` : l'index inverse se construit en parcourant les -sections de l'instance. Deux endpoints suffisent : - -``` -GET /api/Resource/{id}/usages → [{ kind, id, label, configurationId, field, path }] -GET /api/Resource/usage-map?instanceId= → { resourceId: count } -- pour les badges de la grille -``` - -Trois pièges à ne pas rater dans l'implémentation : - -1. `GetReferencedResourceIds` prend une **langue** — il faut l'union sur toutes les langues de la - configuration, sinon une image posée seulement en NL passerait pour orpheline. -2. `Configuration.ImageId` et `LoaderImageId` sont **hors sections** (voir `ConfigurationController.cs:407-414`) : - à inclure, sans quoi l'image d'accueil d'une configuration s'afficherait « jamais utilisée ». -3. `GuidedStep.ImageUrl` est une **URL, pas un id** : une image posée là compterait comme orpheline — - énième argument pour la migration en `ImageResourceId` prévue au lot 4. +**« Utilisée dans »** — ✅ **livré le 2026-09-02** avec la V1. Le contrat réel (forme enrichie de +`usage-map`, `sectionId`, et quatre pièges dont un s'est révélé faux) est dans +[../v1-mediatheque-plan.md](../v1-mediatheque-plan.md) §2.2, qui fait foi. La version de conception +qui figurait ici a été retirée le 13/09 : elle était périmée sur ces deux points. Le filtre « jamais utilisée » vaut pour **toutes** les ressources, pas seulement les générées : c'est lui qui rend visible qu'un asset rattaché à aucun champ n'atteindra jamais un visiteur — et il y en a @@ -266,9 +280,10 @@ manager-app manager-service fal.ai └─ débite les crédits grille de variantes ◄── Succeeded [Valider] ──► POST /assets/{id}/validate - ├─ contrôle quota stockage ◄─ le mur tombe ICI - ├─ compresse (ImageSharp 2560/q82) - ├─ copie vers pictures/{instanceId}/ + ├─ 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 ``` @@ -394,6 +409,11 @@ exposer dans le panneau — trois points de départ, et un curseur qui dit ce qu - **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 @@ -523,9 +543,11 @@ Une colonne mentirait. L'UI affiche donc trois états, dont le troisième est d Le point qui rend la règle vraie : une URL Firebase à jeton est **publique pour qui a le lien**. Un brouillon dans le bucket normal serait servable. D'où le préfixe séparé et l'endpoint proxy. -À la validation, le serveur : contrôle le quota stockage (`413` ici, pas à la génération) → -compresse en 2560 px / q82 (**ImageSharp**, pas `System.Drawing`) → copie → crée le `Resource` avec sa -provenance → l'affecte au champ cible s'il y en avait un. +À 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 — @@ -562,11 +584,16 @@ CreditLedger (append-only, exportable CSV) > `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`. > -> ⚠️ **Reste ouvert** : la recharge côté Stripe. Le webhook ne gère que `checkout.session.completed` -> et `invoice.payment_failed` (`StripeWebhookController.cs:62-65`), rien sur les lignes d'abonnement. -> Un achat de crédits est un paiement ponctuel, donc `checkout.session.completed` suffit — mais le -> mapping session → `Kind: Grant` est à écrire. Pour les premiers clients, un `Grant` posé à la main -> depuis l'écran SuperAdmin suffit. +> **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 @@ -575,8 +602,8 @@ CreditLedger (append-only, exportable CSV) > [immersif-frontiere-plan.md](immersif-frontiere-plan.md) §2bis. **Le plafond dur n'est pas le bon outil contre le stagiaire.** Le scénario « vider un budget en une -après-midi » est un problème **par utilisateur**, pas par organisation : le budget du mois est -justement là pour être dépensé sur le mois. Deux garde-fous distincts : +après-midi » est un problème **par utilisateur**, pas par organisation : le solde est +justement là pour être dépensé. Deux garde-fous distincts : - `StudioCreditsHardCap` — plafond organisation que même un dépassement facturé ne franchit pas. - `StudioPerUserDailyCap` — le vrai rempart : N générations par utilisateur et par jour. @@ -585,6 +612,9 @@ justement là pour être dépensé sur le mois. Deux garde-fous distincts : 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 @@ -599,9 +629,24 @@ public interface IGenerationProvider GenerationKind[] SupportedKinds { get; } Task SubmitAsync(GenerationRequest req, CancellationToken ct); Task 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 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` : @@ -610,7 +655,10 @@ 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 + 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 @@ -621,7 +669,7 @@ Cibles au moment de la rédaction, **à ne jamais écrire dans le code** : | Rôle | Modèle | Note | |---|---|---| -| Image par défaut | FLUX.2 [pro] | jusqu'à 10 images de référence, bonne préservation de style | +| Image par défaut | FLUX.2 [pro] | jusqu'à 10 images de référence — **chiffre non retrouvé sur la page API fal le 13/09, à confirmer au calibrage** (§9.4) ; 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 | @@ -648,9 +696,12 @@ Hangfire worker → provider.SubmitAsync → ProviderRequestId, Status=Running → BackgroundJob.Schedule(PollFallbackAsync(jobId), +90 s) -- filet si le webhook se perd -POST /api/StudioWebhook/fal (HMAC vérifié — précédent : StripeWebhookController) - → télécharge les octets, écrit studio-drafts/, crée GeneratedAsset ×N - → Charge(crédits réels), Status=Succeeded +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 ``` @@ -659,6 +710,20 @@ GET /api/Studio/jobs/{id} polling front à 2 s tant qu'un job est ouvert 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 @@ -922,12 +987,14 @@ le **nom du modèle** dans `onDetectedWithCommand`, que `VoiceOrchestrator._onWa - **Un seul mot « personnage » dans le back-office**, au lieu de trois notions homonymes dans trois écrans. - **La divergence code/plan sur le nombre de guides se résout** : des lignes `Persona` remplacent - les colonnes à plat, la migration déplace les valeurs existantes dans une ligne `Kind = Guide`. + 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.** On appelle un guide, on n'appelle pas le gouverneur. Jamais exposé sur un - `Character` — et un nom custom reste un add-on facturable (entraînement OpenWakeWord). +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. @@ -989,14 +1056,11 @@ Sulafat / Umbriel) : il n'y a aucune source de timestamps. Le lipsync 3 frames n Remplacement : le **portrait canon statique** à côté du lecteur audio — 80 % de la valeur perçue pour zéro travail, puisque le canon existe déjà. Et si du mouvement est voulu plus tard, une **boucle -vidéo de 6 s** (lot 9, Kling/Veo) est un meilleur produit qu'un lipsync 3 frames en 2026. +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. -→ Le talking head devient un **consommateur** de `Persona` : le guide -(`Instance.GuideName` / `GuidePersonaPrompt` / `GuideVoiceId`) gagne un `AvatarId`, la frame 0 est le -portrait canon, les frames 1-2 sortent du pipeline Studio avec son catalogue de modèles en base. -Un pipeline en moins, un modèle en dur en moins, et surtout **un seul mot « avatar » dans le -back-office** — deux objets nommés pareil auraient été une confusion permanente. +> 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 @@ -1046,7 +1110,9 @@ Resource += AiProvenance jsonb? - `rightsHolder` par défaut = nom de l'instance → **les droits sur le contenu généré sont au client**, écrit dans la donnée, pas seulement dans les CGU. -- `AiProvenance != null` suffit à déclencher le badge « image générée par IA » côté visiteur. +- **`Resource.Origin == Generated`** déclenche le badge « image générée par IA » côté visiteur — une + colonne, pas un test sur un jsonb (décision 20). `AiProvenance` porte le détail, et + `Resource.SourceResourceId` pointe l'image source quand il y en a une. - Le libellé du badge est traduit dans **les langues déclarées par la configuration** (`Configuration.Languages`), pas dans les 10 langues supportées. - Descend par `ResourceDTO` → `ExportConfigurationDTO` → les 3 clients visiteurs. @@ -1128,27 +1194,47 @@ POST /api/Studio/personas/{id}/archive ### Crédits ``` -GET /api/Studio/credits?instanceId= → { perMonth, used, reserved, - available, hardCap, +GET /api/Studio/credits?instanceId= → { balance, reserved, available, + expiresAt, hardCap, 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 (§3.0bis) +### Médiathèque — usages + +✅ Livré en V1. Contrat réel : [../v1-mediatheque-plan.md](../v1-mediatheque-plan.md) §2.2-2.3. + +### Ingestion — lot 0 (décision 14) ``` -GET /api/Resource/{id}/usages → [{ kind, id, label, configurationId, field, path }] -GET /api/Resource/usage-map?instanceId= → { resourceId: count } +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 ``` -`ResourceDTO` gagne `fileName` (déjà en base, absent de `ToDTO()`), `width`, `height`, `usageCount`. +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] + HMAC +POST /api/StudioWebhook/fal [AllowAnonymous] + signature ED25519 (§3.7) ``` ### Administration @@ -1162,7 +1248,8 @@ GET/PUT /api/Studio/admin/templates [Policy = SuperAdmin] ## 5. Schéma de données -Une migration, additive, aucune donnée existante touchée — sauf `GuidedStep` (voir plus bas). +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** @@ -1173,7 +1260,7 @@ Une migration, additive, aucune donnée existante touchée — sauf `GuidedStep` | `GenerationModels` | `Key` unique | | `GenerationJobs` | `InstanceId`, `ConfigurationId?`, `UserId`, `VisualIdentityId`, index `(InstanceId, Status)` | | `GeneratedAssets` | `GenerationJobId` FK cascade, `ResourceId?` FK | -| `Personas` | `InstanceId`, `Kind`, index `(InstanceId, IsArchived)` — remplace les colonnes `Guide*` de `Instance` | +| `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 | | `BeforeAfterPairs` | `AfterResourceId`, `BeforeResourceId` | @@ -1182,7 +1269,9 @@ Une migration, additive, aucune donnée existante touchée — sauf `GuidedStep` **Colonnes ajoutées** ``` -Resource += AiProvenance jsonb? +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) Instance += StudioEnabled, StudioCreditsBalance, StudioCreditsExpireAt, StudioCreditsHardCap, StudioPerUserDailyCap, StudioProviderRegion, IsAiWatermarkBurned @@ -1191,13 +1280,21 @@ User += CanValidateAssets VisitorQuestion += PersonaId? -- sans lui, les stats restent agrégées sur un -- assistant imaginaire : impossible de voir qu'un -- personnage répond mal -ResourceType += Model3D -- valeur 11, EN FIN D'ENUM, jamais au milieu +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 `UploadAsync`, `CopyAsync`, `ProbeSizeAsync`. Le credential est - déjà chargé pour FCM ; seul `Firebase:StorageBucket` est vide en config. +- `IResourceBlobService` : ajouter `CreateUploadUrl` (URL V4 signée en `PUT`), `UploadAsync`, + `ReadAllAsync`, `CopyAsync` (copie côté Google : les octets ne passent pas par le VPS), + `GetInfoAsync`. Le credential de compte de service est déjà chargé pour FCM et sait signer ; seul + `Firebase:StorageBucket` est vide en config. + ⚠️ **Un objet écrit par le SDK GCS n'a pas d'URL Firebase.** Poser la métadonnée + `firebaseStorageDownloadTokens` (un GUID) à l'écriture, et construire + `https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{chemin encodé}?alt=media&token={guid}` — + sinon `Resource.Url` reste vide et les trois clients visiteurs n'affichent rien. +- `ResourceIngestionService` : seul point d'entrée qui écrit le fichier d'une `Resource`, quelle que soit sa + provenance (décision 14). Détail au §8, lot 0. - `GuidedStep.ImageUrl` → `ImageResourceId`, et l'inclure dans `GetReferencedResourceIds`. Sans ça les images générées de l'escape game ne partent pas offline. **Migration de données.** - `ImageHelper` → ImageSharp. Le code actuel ne tourne pas sur Linux. @@ -1238,19 +1335,24 @@ le badge et le bloc de provenance. # ═══ V2 ═══ -### Lot 0 — Socle de stockage serveur *(prérequis dur)* +### Lot 0 — Socle de stockage et ingestion serveur *(prérequis dur)* -`IResourceBlobService.UploadAsync/CopyAsync`, config du bucket, `ImageHelper` en ImageSharp, -compression serveur 2560/q82, ajout de `LB`. +`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`, -export CSV. **Crédits rechargeables à expiration, pas de reset mensuel** (§3.5) : le balayage de -péremption est un job Hangfire, précédents en place (`AuditLogPurgeService`). Troisième jauge dans le pied de menu + popover au clic ; détail et journal dans -**Abonnement**. Corriger au passage le gating de l'entrée Abonnement (`main_screen.dart:547-550`), +`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)* @@ -1260,9 +1362,11 @@ Encore aucune génération : l'écran se valide sur sa seule ergonomie. ### Lot 3 — Couche fournisseur + un modèle -`IGenerationProvider`, `FalAiGenerationProvider`, catalogue `GenerationModels` en base, -`POST /generate`, job Hangfire, webhook HMAC, polling de secours. Un seul gabarit pour commencer : -`puzzle-decor`. +`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 @@ -1271,11 +1375,13 @@ Onglet « Générer » dans `ResourceTab` / `showNewResource`, et affordance dan et l'onglet Ressources, sans toucher un seul éditeur (voir §3.0). Panneau : gabarit, 2-3 champs, coût estimé, 4 variantes, validation, affectation au champ quand un `target` est connu. **Correction `GuidedStep.ImageUrl` → `ImageResourceId` ici**, sinon rien ne part offline. -Gabarits `puzzle-decor`, `historical-object`, `scene-evocation`. +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** (lot V1-A). Ne reste ici que ce que le Studio y ajoute : +La refonte elle-même est en **V1** — codée le 2026-09-02 +([../v1-mediatheque-plan.md](../v1-mediatheque-plan.md)), checklist navigateur encore à passer. Ne reste ici que ce que le Studio y ajoute : la facette **Origine › Générées par IA**, le badge sur les vignettes, et le bloc **provenance** dans le panneau de détail. @@ -1292,7 +1398,8 @@ Registre de personnages, grille d'archétypes neutres (une douzaine à pré-prod canon en **jeu de vues**, jauge de budget de références, réinjection en référence n°1, archivage. **Migration structurante** : les 4 colonnes `Instance.Guide*` deviennent une ligne -`Persona { Kind = Guide }`, et `Instance` porte un `GuidePersonaId`. Le catalogue `TtsVoice` passe +`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)* @@ -1318,7 +1425,7 @@ animé, sous une forme qui tient en 2026. ### Lot 11 — 3D -Meshy 6 / Tripo, objets isolés, export GLB, `ResourceType.Model3D`. Suppose un viewer GLB côté +Meshy 6 / Tripo, 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`). ⚠️ **Ce lot faisait quatre lignes et se contentait de renvoyer au plan VR, qui lui-même renvoyait @@ -1339,10 +1446,10 @@ la première migration du Studio, pas en arrivant au lot 11. | 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 | +| 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 dépenser le budget du mois en un après-midi | Ajout d'un plafond **par utilisateur et par jour** | +| 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 | @@ -1364,3 +1471,311 @@ la première migration du Studio, pas en arrivant au lot 11. | 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 + +**Backend** + +1. `IResourceBlobService` : `CreateUploadUrl(storagePath, contentType, maxBytes)` → URL V4 signée en + `PUT`, valable 15 min, en-têtes `Content-Type` et `x-goog-content-length-range: 0,{plafond}` inclus + dans la signature ; `ReadAllAsync`, `UploadAsync`, `CopyAsync`, `GetInfoAsync`. Toute écriture + sous `pictures/` pose la métadonnée `firebaseStorageDownloadTokens` (§5). Bucket non configuré → + `503`, jamais un succès muet. +2. `POST /api/Resource/upload-url` : plafond du type (`Storage:MaxUploadBytes:{ResourceType}`, + décision 18) et quota contrôlés **sur la taille annoncée** — un premier filtre, pas la vérité ; id + réservé pour une création ; URL vers `incoming/{instanceId}/{resourceId}`. + `ResourceIngestionService.IngestAsync(sourcePath, …)` — **le seul code qui range le fichier d'une + `Resource`**, quelle que soit sa provenance : `incoming/` pour un upload, `studio-drafts/` pour le + Studio, un chemin temporaire pour le TTS. Dans l'ordre : `GetInfoAsync` → plafond et quota **sur + la taille réelle** (`413`, objet source supprimé) → post-traitement → écriture sous + `pictures/{instanceId}/{resourceId}` → ligne `Resource` (`SizeBytes`, `Width`, `Height`, + `FileName`, `StoragePath`, `Url`, `Origin`, `DateUpdate`) → suppression de l'objet source. Échec à + l'écriture de la ligne → blob rangé supprimé : plus de ligne sans fichier ni de fichier sans ligne + (anomalie de [media-storage-plan.md](media-storage-plan.md) §3). +3. Post-traitement image en **ImageSharp**, aux règles exactes d'`ImageCompressor.dart`, qui fait foi : + 2560 px côté long, JPEG q82, PNG à alpha gardé en PNG, résultat plus lourd que l'original → original + conservé, `Image360` / `Video360` / `Model3D` jamais touchés. Aucun watermark : sorti du lot 0, voir la carte + kanban 305 (il était inactif depuis l'upload direct). Les autres types passent tels quels. +4. **Ce que le serveur lit, et ce qu'il ne lit pas.** Une image (≤ 30 Mo) est lue depuis le bucket, + traitée en mémoire, et **au plus 2 images sont traitées en même temps** (sémaphore) : un dépôt de + 50 photos ne ralentit pas l'API. Vidéo, audio, documents, 360 et GLB ne sont **jamais lus** : + `CopyAsync` côté Google, puis suppression de la source. +5. Route `POST /api/Resource/ingest` (§4). Supprimer `ResourceController.Upload` et `ImageHelper` + (`System.Drawing`) ; si plus rien n'en dépend, retirer `MemoryBufferThreshold = int.MaxValue` de + `Startup.cs:109-113`. +6. Migration `AddResourceOrigin` : `Origin int not null default 0`, `SourceResourceId text null` ; + `ToDTO()` expose les deux. +7. `LB` dans `SupportedLanguages` (`appsettings.json`), dans `constants.dart` de manager-app, et son drapeau. + +**Infra** (bucket `mymuseum-3b97f.appspot.com`) + +- **CORS** : origines du manager (préprod, prod), méthode `PUT`, en-têtes `Content-Type` et + `x-goog-content-length-range` — sans quoi le navigateur refuse l'envoi. +- **Cycle de vie** : règle `age: 1` sur le préfixe `incoming/` — un envoi jamais ingéré disparaît seul, + sans job Hangfire. S'ajoute à la règle des versions non courantes posée le 07/09. +- **Règles Storage : écriture fermée à tous les clients**, lecture inchangée. Relever les règles + actuelles avant. ⚠️ À appliquer **après** la mise en prod du nouveau manager-app : fermer avant + casse l'upload de la version encore déployée. + +**manager-app** + +8. `create` et `_replaceFile` (`resources_screen.dart`) : `upload-url` → `PUT` direct avec les + `requiredHeaders` (progression affichée) → `ingest`. `ImageCompressor` **reste** côté client + (≈ 12× moins d'octets à monter) ; le serveur réapplique les mêmes règles, sans effet sur une image + déjà traitée. +9. Plus aucun appel au SDK Firebase Storage : supprimer `_deleteBlob`, le pré-contrôle de quota client + (le serveur fait foi : `413` → message traduit) et la dépendance `firebase_storage`. + +| # | Vérifiable par | +|---|---| +| 1-2 | `dotnet test` : quota refusé sur la taille annoncée, puis sur la taille réelle quand le client a annoncé 0 ; objet source supprimé après `413` ; ligne en échec ⇒ blob rangé supprimé | +| 3-4 | `dotnet test` : JPEG 4000 px → 2560 ; PNG à alpha → PNG ; `Image360` intact ; image déjà petite → octets identiques ; vidéo copiée, jamais lue | +| 1 | URL expirée, ou fichier au-delà du plafond signé → Google refuse le `PUT` | +| Infra | Préprod, vidéo de 450 Mo : `docker stats` — ni la RAM ni le réseau de l'API ne bougent | +| Infra | Règles fermées : un `putData` Firebase sans URL signée est refusé | +| 5-6 | Swagger : `POST /upload` a disparu ; migration appliquée en préprod | +| 8-9 | `flutter build web` ; créer, remplacer, supprimer une image, un PDF, un MP3, une 360 ; chaque URL s'ouvre dans visitapp-web et dans l'app mobile | +| — | Remplacer une image d'une visite déjà téléchargée : l'app la re-télécharge | +| — | Fichier au-dessus du plafond de son type → `413`, message traduit en FR, EN et NL | +| — | Non-régression du sélecteur de ressource (plan Médiathèque §3.3) : champ image d'une section, ajout d'un fichier depuis la modale, enregistrer | + +### Lot 1 — crédits + +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 → plafond dur → solde disponible. Refus en codes + (`studio_disabled`, `daily_cap`, `hard_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 + +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 + +1. Migration `AddGeneration` : `GenerationModels`, `GenerationTemplates`, `GenerationJobs`, + `GeneratedAssets`, `Resource.AiProvenance`, `User.CanValidateAssets` ; seed `image-default` (§3.6) + et gabarit `puzzle-decor` (§9.3). +2. `IGenerationProvider` au contrat multi-fichier + `FalAiGenerationProvider` ; clé en variable d'environnement. +3. `PromptAssembler` : ordre du §3.3 ; références dans l'ordre canon → identité → source, **tronquées + au `MaxReferenceImages` du modèle**, et la troncature renvoyée (`references{…, truncated}`). + `EffectivePrompt` toujours stocké. +4. `POST /estimate`, `POST /generate`, `GET /jobs/{id}`, `GET /jobs`, `POST /jobs/{id}/cancel` ; job + Hangfire (un id, jamais l'objet) ; webhook ED25519 (§3.7) ; relance de secours à +90 s. +5. `studio-drafts/` + `GET /assets/{id}/content` authentifié ; `discard`, `reject`, `regenerate` ; + balayage Hangfire à 30 jours. +6. `POST /assets/{id}/validate` sous `Policy = AssetValidation` → `ResourceIngestionService` + (`Origin = Generated`) ; permission `Manager.assetvalidation` (§3.10). `AiProvenance` s'écrit ici ; + sa descente vers les clients visiteurs reste au lot 6. +7. **Calibrage** (§9.4), puis **unité, packs et `CreditCost`** fixés sur les coûts mesurés et écrits + dans ce plan (décision 16). + +| # | Vérifiable par | +|---|---| +| 3 | Test : identité à 5 refs + source, modèle à `MaxReferenceImages = 4` → 4 envoyées, `truncated = true`, canon jamais tronqué avant l'identité | +| 4 | Tests : signature invalide → `401` ; même `request_id` reçu deux fois → un seul `Charge` ; échec fournisseur → `Refund` | +| 5 | Un brouillon n'a aucune URL publique : chemin sous `studio-drafts/`, `content` refusé sans jeton | +| 6 | `ContentEditor` sans permission → `403` sur `validate` ; avec → `Resource` créée, `Origin = Generated`, quota compté à ce moment et pas avant | +| 7 | Critères du §9.4 tenus ; prix écrits | + +### Lot 4 — « Générer » dans le sélecteur, et le gate + +1. Onglet « Générer » dans `ResourceTab` / `showNewResource` : un seul branchement (§3.0), qui apparaît + dans les 13 types de section, les POI, les étapes et la Médiathèque. Absent si `StudioEnabled == false`. +2. Panneau : gabarit → 2-3 champs → point de départ (texte | image, décision 15) → coût estimé → + 4 variantes → valider → affectation au champ si `target` est connu. Polling à 2 s tant que le job + est ouvert ; fermer le panneau → `discard` des variantes non retenues. +3. `GuidedStep.ImageUrl` → `ImageResourceId` : migration `GuidedStepImageResource` (retrouver la + `Resource` par URL, sinon en créer une de type `ImageUrl`), inclusion dans + `GetReferencedResourceIds`, lecture du nouveau champ par les trois clients visiteurs, et retrait de + la limite dans le tooltip « Jamais utilisées » de la Médiathèque. +4. Gabarits `historical-object` et `scene-evocation` ajoutés au seed, calibrés comme au lot 3. + +| # | Vérifiable par | +|---|---| +| 1 | **Non-régression du sélecteur** : ouvrir un champ image, choisir une ressource existante, enregistrer | +| 2 | Générer depuis un champ d'étape : l'image validée est posée dans le champ sans autre geste | +| 3 | Une étape à image générée part dans l'export offline et s'affiche hors ligne ; elle n'apparaît plus dans « Jamais utilisées » | +| **Gate** | **Un conservateur, seul devant l'écran, produit 10 images cohérentes.** Résultat consigné dans `DOCS/test-plan*.md`, rapporté par Thomas. Échec → on reprend le §9 et le panneau ; le lot 5 ne s'ouvre pas | + +--- + +## 9. Catalogue v0 — styles, préambule, gabarits + +> **Seed de départ, pas un contenu validé** (décision 19). Tout vit **en base**, éditable en +> SuperAdmin, et le calibrage de fin de lot 3 (§9.4) le corrige avant qu'un client ne le voie. Les +> textes destinés au modèle sont **en anglais** — les modèles image y répondent mieux, et ils ne sont +> jamais affichés. Les libellés visibles sont des `List` 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, monochrome ink on aged paper` | +| `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 = + "no text, no letters, no watermark, no signature, no logo" -- toujours + + ", {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. + +⚠️ Toutes les variantes de FLUX.2 n'ont pas de `negative_prompt` séparé : le négatif est alors ajouté au +prompt sous la forme `Avoid: …`. À établir modèle par modèle au calibrage, et à consigner dans +`GenerationModel.Params`. + +### 9.3 Gabarits du MVP + +Contexte de fiche (§3.3, point 4), ajouté après le corps de chaque gabarit : +`"Context: {titre de la fiche}. {description en texte brut, 300 caractères max}"`, 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.