Compare commits

...

15 Commits

Author SHA1 Message Date
Thomas Fransolet
d879766cae Studio : lot 8f (VR) et repasse des lots 0 à 8 côté casque
Carte 315 : mention IA du lot 6 absente en VR, audio narré et casting à porter, couture ScenePersona vers Persona, Parcours probablement vide sur le casque (à vérifier). Plan et STATUS à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 23:06:50 +02:00
Thomas Fransolet
78517cb38f Studio : lot 8e ajouté au plan, STATUS, kanban et plan de test à jour
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 23:00:46 +02:00
Thomas Fransolet
dff2f6ba37 Studio : lot 8 bouclé côté manager, STATUS, kanban et plan de test à jour
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 22:47:20 +02:00
Thomas Fransolet
a91a20d0e4 Studio : lot 8bis et son plan de test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 22:41:41 +02:00
Thomas Fransolet
f9d5e9cb6b Studio : lot 8d et plan de test de la narration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 22:18:23 +02:00
Thomas Fransolet
d8d62e7713 Plan Studio : suite du lot 8c
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 21:46:37 +02:00
Thomas Fransolet
aa80090ef8 Plan Studio : lot 8c côté parcours
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 21:12:42 +02:00
Thomas Fransolet
96ae59c889 Plan Studio : lot 8b fait
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:54:31 +02:00
Thomas Fransolet
c0b7140f41 Plan Studio : découpage du lot 8, 8a fait
8b pipeline TTS (bouton explicite, crédits Studio, MP3 via ffmpeg), 8c onglet Narrateurs, 8d portrait côté visiteur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:37:04 +02:00
Thomas Fransolet
b9f1a692ab Studio : lots 5-6 et calibrage §9.4 consignés, unité de crédit, lot 7 en cours
Résultats du calibrage (61 générations, coûts recoupés sur l'export fal),
trois corrections de prompt, unité 1 crédit = 0,015 $ vendue avec marge,
échantillons et figures d'archétypes embarqués ; planches contact dans
outputs/studio-calibrage-2026-09-15. STATUS, roadmap, cartes 280 et 310 à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:09:53 +02:00
Thomas Fransolet
556391cf59 Docs en attente : plan SectionForm, cartes 020 (règles Storage) et 285 (loader)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:09:53 +02:00
Thomas Fransolet
6221978526 Docs en attente : canal VR et contenu immersif livrés, SectionForm, roadmap
Cartes 600, 610 et 620 closes (back-office XR, ressource 360, appairage tablette),
250 et 320 retirées du planifié, 015 et 330 à jour ; STATUS, roadmap, test-plan,
todo-features et plans VR / frontière immersif alignés ; kanban.html regénéré.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:06:34 +02:00
Thomas Fransolet
59939ddb87 Studio : lots 0 à 4 codés, décision 23, cartes 280 et 300 à jour
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:07:01 +02:00
Thomas Fransolet
1fe76470ab Plan Studio : lot 1 livre, decision 22
Plafond dur organisation retire : avec des credits prepayes, le solde est deja
le plafond. SubscriptionPlan.HasStudio / StudioCreditsGranted et
Instance.StudioProviderRegion ne sont pas crees tant que rien ne les lit. API
credits, schema et etapes du lot 1 alignes sur le code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:51:21 +02:00
Thomas Fransolet
d70d851b6f 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) <noreply@anthropic.com>
2026-09-13 17:51:23 +02:00
36 changed files with 2041 additions and 260 deletions

View File

@ -2,7 +2,7 @@
> Point d'entrée unique : "où j'en suis, qu'est-ce que je peux avancer". Chaque section renvoie vers le document détaillé pertinent — ce fichier ne duplique pas le contenu, il donne juste la vue d'ensemble et l'état.
>
> Dernière mise à jour : 2026-09-02.
> Dernière mise à jour : 2026-09-15.
---
@ -17,7 +17,8 @@ Résumé de la table de suivi de ce fichier :
- **🧪 Codé et committé le 2026-09-10 (rien de poussé), pas encore testé** : scan d'id brut (QR du Fort) depuis l'accueil, drapeau « afficher le scan QR » et **nom de l'application** par app (Mobile/Web), liens stores (SuperAdmin), QR du manager vers la nouvelle page **`app.myinfomate.be/download/…`**, suggestions de proximité beacon + GPS avec **notification écran verrouillé** (mobile) et suggestion GPS dans la page (web). Tests : [test-plan.md §23](test-plan.md) ; restes hors code (redirection `web.mymuseum.be`, déclarations stores) : carte kanban « QR codes, nom d'application… ».
- **À faire** : dashboard super-admin V2, facturation V2. ✅ **Le Stripe Customer Portal est complet le 2026-08-13** (backend + bouton manager-app).
- ⚠️ **Cette ligne était périmée sur six points, revérifiés dans le code le 2026-08-13.** Sont **faits** : écran audit log manager-app (front livré le 12/08), **rate limiting API Keys** (livré, pas seulement décidé — politique `ai` dans `Startup.cs:279`, `UseRateLimiter()` en `:344`, les deux endpoints décorés), gestion des users par instance (plafond serveur + compteur UI, 12/08), nettoyage `SectionEvent.ParcoursIds` (11/08), et le **backend** du Customer Portal (13/08). L'**unification `QuestionType`** n'est pas « à faire » mais **écartée** : le trio TextLibre/Digicode/ExpectedAnswer n'a jamais existé dans le code, l'enum a trois valeurs et le digicode est un comportement dérivé, pas un type — reste une propreté front, elle aussi livrée (alias nommés, 11/08).
- **📦 Reporté en V2 (décidé le 2026-08-07)** : **SectionForm**, **ressource 360°**, **AR image tracking (Mind AR)**. Les trois sont spécifiés en détail dans [todo-features.md](todo-features.md) mais sortent du périmètre V1 — aucun n'est un prérequis de la migration Postgres ni de la mise en prod. À reprendre tels quels quand la V1 sera stabilisée.
- **📦 Reporté en V2 (décidé le 2026-08-07)** : **SectionForm**, **ressource 360°**, **AR image tracking (Mind AR)**. Les trois sont spécifiés en détail dans [todo-features.md](todo-features.md) mais sortent du périmètre V1 — aucun n'est un prérequis de la migration Postgres ni de la mise en prod. À reprendre tels quels quand la V1 sera stabilisée. **SectionForm a été analysé dans le code le 2026-09-15** : plan complet dans [v2/section-form-plan.md](v2/section-form-plan.md) (réponses **anonymes**, les **trois** fronts visiteur kiosk compris, table `FormSubmission` dédiée, réponses jamais indexées pour le RAG) — **ne pas refaire l'analyse**.
- **📦 Reporté en V2 (décidé le 2026-09-15)** : **loader animé composable par le client**. Aujourd'hui le loader est une image fixe (`Configuration.LoaderImageUrl`), et ça suffit pour la V1. La suite est **décidée, pas exécutée** : on transporte des **paramètres** (`{preset, couleurs[], logoResourceId, vitesse}`) et chaque front rend 5-6 presets nativement — **pas de format de fichier animé**. ⛔ SVG animé et Lottie ont été évalués et **écartés** (`flutter_svg` ignore SMIL et les keyframes CSS ; aucun runtime Lottie sérieux sur Unity/Quest) — **ne pas refaire l'analyse des formats**, tout est dans la carte kanban « Loader animé — presets paramétriques » (`cards/5-planifie/285-…`).
- **🐛 Bug ouvert (relevé le 2026-08-25)** : **la jauge de stockage du menu latéral ne se rafraîchit pas** après lajout (ni la suppression) dune ressource. `QuotaBarsWidget` nappelle `instanceGetQuota` quune fois, dans `initState` (`quota_bars_widget.dart:23`), et il vit dans le pied du menu **persistant** (`main_screen.dart:477`) : le chiffre affiché date de louverture de session. ⚠️ **Le comptage serveur est correct**`storageUsedBytes` est recalculé à chaque appel (`SUM(SizeBytes)`, `InstanceController.cs:383`) et le contrôle dupload refait un `GET /quota` avant de téléverser (`resources_screen.dart:186-198`). Doù lincohérence visible : une jauge à « 0 KB » et un refus 413 sur le même chiffre. Détail dans [todo-features.md](todo-features.md) — Quota stockage.
**Prochaine chose à faire si tu as 1h** : jouer le **§19.13 cas E** du plan de test (chasse au trésor / carnaval) de bout en bout, depuis la création dans manager-app. C'est le scénario le plus complet — il valide SectionMap + SectionParcours + géodéclenchement + questions + mode jeu d'un coup.
@ -155,7 +156,28 @@ Dix-neuf chantiers de canaux et d'IA, chacun avec son plan détaillé. État au
- **V1****visiteur web** : `visitapp-web` build ✅, les 13 types de section rendus, assistant IA inclus ; **il ne manque que le Dockerfile et l'entrée `app.myinfomate.be` au compose**. Bloquant, le plan Essentiel vendu par l'onboarding est web-only. Plus l'**écran Guide IA**. La **Médiathèque** (basculée en V1 le 2026-09-02) est **codée de bout en bout le même jour** — index inverse des usages, panneau de détail, facettes, suppression protégée, les deux bugs corrigés ; `dotnet test` 236/236 et `flutter build web` verts, **mais la checklist de 15 points reste à passer dans un navigateur** (colonne « À tester » du kanban). Voir [v1-mediatheque-plan.md](v1-mediatheque-plan.md).
- **Livrés** — export PDF des stats à la demande, historique 13 mois pour tous les plans, refonte de l'écran Statistiques (⚠️ jamais ouvert dans un navigateur).
- **V2** — module Studio (génération IA d'images), Personnages (une entité `Persona` unifiant trois plans), TTS pré-généré, kiosk web, VR Meta Quest, import IA + RAG. ⛔ **Prérequis dur commun au Studio et au TTS** : le backend ne sait pas écrire dans le bucket (`IResourceBlobService` n'a que `DeleteAsync`).
- **V2** — module Studio (génération IA d'images), Personnages (une entité `Persona` unifiant trois plans), TTS pré-généré, kiosk web, VR Meta Quest, import IA + RAG. 🛠️ **Studio : lots 0 à 6 codés et commités (branche `studio`, pas encore poussée), calibrage §9.4 fait le 2026-09-15** (≈ 3 $ réels, unité de crédit arrêtée : 1 crédit = 0,015 $ de coût fal, prix de vente avec marge). Lot 7 : échantillons de style et figures d'archétypes embarqués, reste l'écoute des voix. **Lots 8 et 8bis codés et commités le 2026-09-15, jamais exécutés en réel** — narrateurs assignables et chaîne de résolution, génération audio Gemini TTS → MP3 (1 crédit par minute, état « à régénérer »), onglet Narrateurs du parcours, blocs sur l'article et le point de carte, cartes Narration et Personnages dans l'écran Configuration, portrait du narrateur et écran « les personnes que vous allez rencontrer » sur les trois apps visiteur, lecteur audio dans la fiche d'un point de carte (lot 8e, ajouté le même soir). ⚠️ Au passage, les apps visiteur ne jouaient pas un audio rangé par identifiant de ressource — corrigé. **À faire** : passer [test-plan-studio-narration.md](test-plan-studio-narration.md) (clé Gemini + ffmpeg), extraits d'écoute des voix, lot 8f (la narration et le casting dans l'app VR, carte 315), test du conservateur (gate lot 4), push de la branche `studio`. Le prérequis « le backend ne sait pas écrire dans le bucket » est levé depuis le lot 0.
- 📄 **La frontière entre le Studio IA et le canal XR est tranchée depuis le 2026-09-11** : [v2/immersif-frontiere-plan.md](v2/immersif-frontiere-plan.md). Les deux plans se renvoyaient la balle (le Studio mettait la 3D en « dépend du chantier VR », le plan VR décrivait les POI sur GLB sans dire d'où venait le GLB). Trois couches : ressources immersives (360/GLB, **se vendent sans casque**), consommation, et le Studio comme *source d'approvisionnement* de la première. **Cinq décisions de rétro-compat touchent des lots antérieurs à la 3D** — à lire avant la première migration du Studio, pas en arrivant au lot 11.
- ✅ **Le backend du canal XR est fait le 2026-09-11** (lot `XR-1` de [v2/vr-quest-unity-plan.md](v2/vr-quest-unity-plan.md)) : `Device.AppType` + migration, `DeviceController` résout l'`ApplicationInstance` sur le canal au lieu de `Tablet` en dur, `Get` filtre par canal, `ApiKeyAppType.VrApp`, `appType` dans `manager_api_new`. 224 tests au vert. Et **`XR-3` était déjà fait** — l'export est ouvert aux apps depuis `9cc45c5`, le plan était en retard sur le code.
- ✅ **`XR-2` fait le 2026-09-12 — le canal VR est administrable.** L'écran XR est une coquille à deux sous-onglets : « Configuration » réutilise `AppConfigurationLinkScreen` tel quel (déjà générique sur l'`appType`, zéro code neuf), « Casques » est la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL. ⚠️ Deux affirmations du plan étaient fausses : la grille kiosk liste des `AppConfigurationLink`, pas des `Device` (le filtre par canal était déjà implicite, et le paramètre `appType` de `/api/device` ne sert pas à cet écran), et batterie/version/dernier vu ne sont que dans `DeviceDetailDTO` — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités. Ne restent que deux puces documentaires de `XR-3` (contrat d'export versionné, décision multilingue).
- ⚠️ **Nommage** : les lots du chantier XR s'appellent `XR-1``XR-5` (renommés le 11/09, ils s'appelaient `V-1``V-5` et se confondaient avec « V1 »). Les lots du backlog V1 sont lettrés `A``J` dans [v1-plan.md](v1-plan.md).
- ✅ **L'app Unity tourne sur un vrai casque depuis le 2026-09-11** (lot `XR-4`, items `E0` et `E1` sauf l'Interaction SDK) : Unity 6000.0.83f1, projet URP dans [`vr-app/`](../vr-app/README.md), APK sideloadé sur un Quest 2 en Horizon OS v207. **Le prérequis qui bloquait tout le lot est levé.** Mesuré sur le casque : un décor glTF de 50 Mo charge en **1,4 s** et tient **72 FPS**, mais avec le GPU au maximum — le budget de scène devra être chiffré. La **conversion d'axes glTF→Unity est prouvée** (`NegateX`), au repère de calibration, donc elle ne se redémontre plus. - ⚠️ **Deux numérotations E0-E<n> se confondaient** : celle de `XR-4` (E0-E11, l'app Unity complète) et celle du plan de la piste Scène 3D. La seconde est renommée **S0-S8** le 11/09 — [vr-app/docs/04-phase3-plan.md](../vr-app/docs/04-phase3-plan.md). **Le plan unifié gouverne** ; en cas de contradiction, le document le plus récemment mis à jour tranche. Fait dans la piste S : S0, S1 (sauf le cercle de navigation), S2 ; S3 (viewer web) écrit mais jamais essayé. - ➡️ **Prochain travail sur le canal XR** : le POC de `XR-4`, soit `E2` (appairage par pincode, `POST /api/device`) → `E3` (lecture de `GET /api/configuration/{id}/export`) → `E5` (menu flottant) → `E6` (360°). E2 et E3 sont débloqués par XR-1 et XR-3, tous deux faits.
- ⚠️ **Deux numérotations E0-E<n> se confondaient** : celle de `XR-4` (E0-E11, l'app Unity complète) et celle du plan de la piste Scène 3D. La seconde est renommée **S0-S8** le 11/09 - [vr-app/docs/04-phase3-plan.md](../vr-app/docs/04-phase3-plan.md). **Le plan unifié gouverne** ; en cas de contradiction, le document le plus récemment mis à jour tranche. Fait dans la piste S : S0, S1 (sauf le cercle de navigation), S2 ; S3 (viewer web) écrit mais jamais essayé.
- 🟡 **`E2` et `E3` écrits le 2026-09-12, jamais essayés** : `vr-app/.../Net/``ApiClient` (le seul point de contact avec le backend), `PairingService` (pincode → clé d'API → `POST /api/device` avec `appType = 3`, appairage persistant), `ConfigurationExport` (un appel pour tout le contenu), et `PairingBootstrap` qui affiche le résultat dans le casque. Compile contre les DLL Unity + Newtonsoft ; **rien n'a encore parlé à un vrai serveur ni tourné sur le Quest**. ⚠️ Confirmé au passage : l'export **ne rend qu'une langue** (`GetReferencedResourceIds(language)`), donc changer de langue à chaud impose de recharger — c'est la question ouverte de XR-3. ⚠️ Et les scripts Unity parlaient encore `E1`/`E2` pour `S1`/`S2` : renommés `S1Bootstrap`/`S2Bootstrap` le 12/09.
- 🟡 **`E4` (cache offline) et `E9` (télémétrie) écrits le 2026-09-12, jamais essayés** : `ContentCache` (écriture atomique — une borne débranchée le soir ne laisse pas de JSON tronqué), `ContentService` (**cache d'abord**, rafraîchissement de fond dont l'échec est silencieux), `Telemetry` (« tire et oublie »). ⚠️ La route des stats est **`/api/stats/event`**, et `appType` y part en **chaîne parsée par nom** : une faute de frappe retombe sur `Mobile` sans erreur, et les visites du casque seraient comptées en mobile.
- 🟡 **`E5` — le menu flottant, écrit le 2026-09-12, jamais essayé** : arc de panneaux à 2,2 m qui **ne suit pas la tête**, vignettes chargées après coup, télémétrie branchée, et **trois moyens de viser** — manette (gâchette), main (pincement), tête (temporisation 1,2 s). ⚠️ **« À la tête », pas « aux yeux »** : le Quest 2 n'a pas d'eye tracking, seul le Quest Pro en a. ⚠️ **La dépendance `E5 → E1` du plan était fausse** : `OVRInput` et `OVRHand` viennent du **Core** SDK, déjà installé — l'Interaction SDK n'apporte que le confort. **E1 sort du chemin critique du POC** (voir le §8bis du plan).
- 🟡 **`E8` — quatre types de section, écrits le 2026-09-12, jamais essayés** : Slider, Map, Parcours et Event, tous dans la même vue paginée (`PagedView` + `SectionPages`) — une chose à la fois, grande, devant soi. ⚠️ **Deux écarts assumés** : la **Map est une liste de POI**, pas la maquette 3D promise (ça demande `SectionModel3D`, et une carte Google sur panneau flottant serait *moins* bonne qu'un téléphone) ; le **Parcours** est rendu comme une Map, pour la même raison. L'**Event** ne montre que la journée en cours et retombe sur les dates suivantes si elle est vide. Une section non gérée affiche son titre et le dit.
- 🟡 **`E10` — la session de borne, écrite le 2026-09-12, jamais essayée** : fin de visite à la **repose du casque** (via `CommonUsages.userPresence` d'Unity XR, donc sans SDK Meta ni rien à configurer) ou après **90 s sans activité**, retour au menu **recentré devant le visiteur suivant**, nouvelle session de stats, écran qui ne s'endort pas. ⚠️ Le verrouillage de l'app sur le casque n'est **pas** du code : c'est le mode appareil de Meta.
- ✅ **La ressource 360° est faite le 2026-09-12** — le dernier prérequis du POC est levé. `ResourceType.Image360` (11), `Video360` (12), `Model3D` (13) **en fin d'enum**, sans migration (colonne déjà `integer`), verrouillées par un test valeur par valeur. Côté manager : la pastille de type du sélecteur de médias devient **cliquable** pour déclarer une 360 (aucune extension ne le dit — un panorama est un `.jpg` comme un autre), le `.glb` est accepté et déduit seul, i18n FR/EN/NL. ⚠️ **Le vrai piège n'était pas l'enum mais la compression** : `ImageCompressor` ramène toute image à 2560 px, ce qui ferait d'une équirectangulaire 8192×4096 une bouillie dans un casque — les trois types en sont exclus, sur les **deux** chemins d'upload. ✅ Et le prérequis « le backend ne sait pas écrire dans le bucket » **ne s'applique pas** : `manager-app` pousse directement dans Firebase puis enregistre l'URL.
- 🟡 **`E6` — la lecture 360°, écrite le 2026-09-12, jamais essayée** : image et vidéo équirectangulaires dans le même shader `Skybox/Panoramic`, la vidéo par une `RenderTexture` alimentée depuis le cache disque, et le ciel d'origine restauré à la fermeture (c'est un réglage global). **Le retour au menu ne reste pas affiché** — 4 s au début, puis il s'efface et revient quand le visiteur baisse les yeux ou prend une manette, et il est reposé sous le regard courant : en 360 on tourne sur soi-même. ⚠️ **`Skybox/Panoramic` doit être dans *Always Included Shaders*** — sinon le ciel sort **magenta sur le casque et correct dans l'éditeur**, le piège déjà payé avec glTFast. ⚠️ **Mémoire** : `LoadImage` décode en RGBA32, soit **134 Mo pour une 8192×4096** — compressée en ASTC au chargement et libérée à la sortie, sinon l'app se fait tuer au bout de quelques 360 (invisible sur un essai unique, fatal en borne).
- 🟡 **`XR-5` — la supervision de flotte, l'essentiel écrit le 2026-09-12** : `PUT /api/device/{id}/heartbeat` (batterie, version ; le serveur pose lui-même `Connected` et `LastSeen` — recevoir le battement *est* la preuve) et `FleetReporter` côté casque, toutes les 3 min plus une fois au démarrage. **Battement HTTP plutôt que MQTT** : à 1-3 casques, MQTT coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour **pousser** vers le casque, pas pour remonter. ⚠️ `Update` n'écrivait **ni `AppVersion` ni `LastSeen`** : les deux colonnes que l'onglet XR affiche n'avaient aucun chemin d'écriture.
- 🔴 **Trois bugs de production trouvés et corrigés le 2026-09-12, tous sur le même mur** : outre l'appairage ci-dessous, `GET /api/Device/{id}/detail` était fermé aux clés — or c'est le **premier appel d'une tablette qui démarre**, celui qui lui dit quelle configuration afficher : une tablette déjà appairée ne retrouvait plus son contenu au redémarrage. Et `tablet-app/lib/main.dart:38` reconstruisait son client **sans clé**, alors qu'elle est persistée en base locale. La route refusait les clés, l'app n'en envoyait pas : les deux se tenaient.
- 🔴 `POST /api/device` répondait **403 à toute tablette** depuis le **13/03/2026** — la policy `InstanceAdmin` posée sur `DeviceController` contre une clé d'API qui ne porte que `AppRead`/`Viewer`, et `tablet-app` ne s'authentifie jamais autrement. Une tablette déjà appairée ne rappelle jamais cette route : **seul un nouvel appareil échouait**, donc personne ne l'a vu pendant six mois. ⚠️ **À vérifier sur le terrain** — les tests appellent le contrôleur directement, sans filtre d'autorisation, donc ils ne prouvent rien sur l'accès.
- 🟡 **`E7` — la maquette 3D et ses points d'intérêt, écrite le 2026-09-12, jamais essayée.** `SectionModel3D` est **un vrai type de section**, pas une Map augmentée : une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres. Ce qu'on réutilise, c'est le **`GeoPoint`** — il portait déjà `SectionMapId` *et* `SectionEventId`, un troisième rattachement suit le patron, et les points gardent titre, description, audio et multilingue. Migration `AddSectionModel3DAndPoiPosition` (3 colonnes nullable). ✅ **L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial, n'était pas à écrire** : `vr-app/viewer` sait déjà le faire et **son protocole `postMessage` existait déjà**, pensé pour ce cas — il parle en manifeste de scène, le même objet que lit le casque. Le manager le monte en **iframe**, comme il le fait déjà trois fois ailleurs. Côté casque, `Model3DView` ne dessine rien non plus : il construit un `SceneManifest` depuis la section et le passe au `SceneBuilder` de la piste S. ⚠️ **Le viewer n'a jamais tourné** (S3), et il doit être servi sous le même domaine que le manager.
- 🔴 **Deux trous de l'export trouvés et corrigés le 2026-09-12** — et ils ne concernaient **pas que la VR**, l'export est ce qui part en visite hors ligne. **Un** : il ne portait **aucun champ spécifique de section** (`Section.ToDTO()` n'est pas virtuelle, l'export l'appelait sur une variable de type `Section`) — une galerie sans ses images, une carte sans ses points. **Deux** : les collections des sous-types n'étaient pas chargées, donc une Map exportait zéro point. Corrigés par `SectionFactory.ToDTO` + pré-chargement, verrouillés par six tests. ✅ Au passage, la question multilingue de `XR-3` se tranche toute seule : `language` ne filtre que les **ressources**, l'omettre rend toutes les langues — le casque change donc de langue **sans aucun appel**.
- ✅ **`XR-3` est clos le 2026-09-12** : le JSON d'export est un **contrat versionné** (`exportVersion: 1`, `generatedAt`, règles de compatibilité écrites sur le DTO), et la question multilingue est tranchée par le code lui-même.
- ✅ **`XR-5` est clos aussi** : **pousser une configuration vers le casque sans MQTT** — la réponse au battement porte la configuration assignée, le casque compare et recharge en revenant au menu, sans redémarrage. Coût : trois minutes de latence au pire. Ce que ça évite : une connexion permanente sur une borne et une bibliothèque MQTT dans le build IL2CPP. Le publish serveur existe déjà si un jour trois minutes sont trop. Plus l'**alerte « casque muet »** après une heure sans battement — elle corrige un mensonge, la pastille « connecté » venant du dernier battement reçu.
- ➡️ **Prochain travail sur le canal XR** : **jouer le §25 du [plan de test](test-plan.md)** — 11 blocs, **135 cas**. Le §25.7 (déclarer une 360 dans le manager) et le §25.9.7 (appairer une nouvelle tablette) se jouent **sans casque** ; le reste demande le Quest. **Le POC est écrit en entier** : rien de E2 à E10 n'a jamais tourné, et c'est la seule chose qui manque.
- **Abandonné le 2026-09-02** — talking head : son lipsync reposait sur `enable_time_pointing` de Google Cloud TTS, or le code livré tourne sur Gemini TTS. Le plan était sans fondation, pas « à faire ».
→ **Détail complet, la table des 19 chantiers : [status/roadmap-canaux.md](status/roadmap-canaux.md)**

View File

@ -458,13 +458,13 @@
</header>
<section class="summary" aria-label="Chiffres clés">
<div class="stat"><span class="n n-critical">2</span><span class="k">Urgent</span></div>
<div class="stat"><span class="n n-critical">3</span><span class="k">Urgent</span></div>
<div class="stat"><span class="n n-info">1</span><span class="k">Migration v3</span></div>
<div class="stat"><span class="n n-warn">2</span><span class="k">Bugs ouverts</span></div>
<div class="stat"><span class="n">12</span><span class="k">À tester</span></div>
<div class="stat"><span class="n">39</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n">40</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n n-gate">9</span><span class="k">Bascule prod</span></div>
<div class="stat"><span class="n n-good">63</span><span class="k">Fait récemment</span></div>
<div class="stat"><span class="n n-good">66</span><span class="k">Fait récemment</span></div>
</section>
<div class="filters" role="group" aria-label="Filtrer par domaine">
@ -490,7 +490,7 @@
<!-- URGENT -->
<section class="col" style="--stripe: var(--critical)">
<div class="col-head"><h2>Urgent</h2><span class="count">2</span></div>
<div class="col-head"><h2>Urgent</h2><span class="count">3</span></div>
<div class="stack">
<article class="card" data-area="infra">
@ -508,9 +508,29 @@
→ 200, avec le contenu complet et ses traductions</pre>
<p>La même requête <strong>avec</strong> <code>X-Api-Key</code> renvoie le même 200. <strong>Cause exacte, vérifiée le 08/09</strong> : <code>Get</code> (<code>ConfigurationController.cs:52</code>) et <code>GetDetailAsync</code> (<code>:117</code>) portent un <code>[AllowAnonymous]</code> <em>explicite</em>. 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 (<code>client.dart:54</code>), et le mécanisme existe et fonctionne : <code>ApiKeyAuthenticationHandler</code> + la policy <code>AppReadAccess</code>, déjà utilisée par <code>byPin</code> (<code>:83</code>) et <code>export</code> (<code>:383</code>) du même contrôleur.</p>
<p><strong>Ce que ça expose</strong> : le contenu éditorial de n'importe quelle instance, à qui connaît un <code>instanceId</code> — 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.</p>
<p>⚠️ <strong>À vérifier avant de conclure</strong> : le périmètre exact. J'ai testé <code>/api/Configuration</code> ; il faut passer en revue les autres routes que consomment les apps visiteur (<code>/api/Section/configuration/{id}</code>, <code>/api/Resource/{id}</code>, <code>/api/SectionMap</code>, <code>/api/SectionQuiz</code>) avant de savoir si le trou est ponctuel ou général.</p>
<p>C'est le problème symétrique du 403 sur <code>GET /api/Instance/{id}</code>, <strong>corrigé le 08/09</strong> (voir la carte close) : la même app est <strong>trop bloquée</strong> sur une route et <strong>pas bloquée du tout</strong> sur les autres. Les deux se traitent ensemble, en décidant ce que <code>X-Api-Key</code> est censé garder.</p>
<span class="src">conversation 08/09 — vérification de la préprod depuis internet</span>
<p><strong>Audit complet fait le 12/09</strong><strong>40 routes anonymes</strong> recensées, dont <strong>2 seulement contrôlent la clé</strong> (<code>Configuration/{id}/export</code> et <code>Instance/{id}</code>, tous deux corrigés en septembre). Le trou est donc <strong>général, pas ponctuel</strong> : 24 routes de contenu sont ouvertes — <code>Configuration</code> (2), <code>Section</code> (4), <code>Resource</code> (2), <code>SectionMap</code> (3), <code>SectionEvent</code> (3), <code>SectionAgenda</code> (2), <code>SectionParcours</code> (2), <code>SectionQuiz</code> (1), <code>ApplicationInstance</code> (2).</p>
<p>🔴 <strong>Trouvaille plus grave que la carte, corrigée le 12/09.</strong> <code>GET /api/Instance/slug/{slug}</code> était anonyme <strong>et sans filtrage</strong> : il rendait le <strong>pinCode</strong> — celui qui ouvre l'appairage des tablettes et des casques — plus l'<strong>adresse de facturation</strong>, le <strong>numéro de TVA</strong>, les quotas et le plan du client. Et le slug n'est pas un secret : <strong>c'est l'URL du site visiteur</strong> (<code>app.myinfomate.be/{slug}</code>). La fonction de filtrage <code>StripCommercialFields</code> existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur <code>slug</code> et sur <code>byPin</code>, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : <code>publicApiKey</code>, sans laquelle <code>visitapp-web</code> ne peut pas s'amorcer, et <code>isTrialActive</code>, qui sert au filigrane d'essai affiché au visiteur.</p>
<p>⚠️ <strong>Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas.</strong> La clé s'obtient <strong>anonymement</strong> : par le slug (qui est dans l'URL) ou par <code>app-key</code> 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 <code>instanceId</code>. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et <strong>révocable</strong>, 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.</p>
<p><strong>Reste à faire</strong> : fermer les 24 routes de contenu. Deux obstacles connus. <strong>Un</strong> : <code>[Authorize]</code> de classe et d'action se <strong>combinent</strong> — c'est pour ça qu'<code>export</code> passe par <code>[AllowAnonymous]</code> + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. <strong>Deux</strong> : deux routes doivent <strong>rester</strong> anonymes et c'est écrit dans le code de <code>visitapp-web</code><code>instance/slug/{slug}</code> (l'amorce, qui distribue la clé) et <code>ApplicationInstance?instanceId=</code> (la page <code>/download</code>, où le visiteur arrive d'un QR imprimé sans slug ni clé).</p>
<span class="src">conversation 08/09 — vérification de la préprod depuis internet · audit complet 12/09</span>
</article>
<article class="card" data-area="backend infra">
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">sécurité</span><span class="tag">Studio</span><span class="flag f-critical">À régler impérativement avant/avec la mise en production de la version actuelle</span></div>
<h3>Le bucket Firebase est <strong>ouvert en écriture et en suppression à tous</strong></h3>
<p>Relevé le 14/09 sur le projet <code>mymuseum-3b97f</code> (release <code>firebase.storage/mymuseum-3b97f.appspot.com</code>, ruleset <code>025f98db-…</code>, <strong>inchangé depuis le 08/01/2024</strong>) :</p>
<pre>match /{allPaths=**} {
allow read, write; //: if request.time &lt; timestamp.date(2024, 1, 12)
}</pre>
<p>La garde temporelle du template Firebase a été <strong>commentée</strong> pour qu'elle n'expire pas. N'importe qui connaissant le nom du bucket — il est dans l'URL de chaque image, donc public — peut <strong>lire, lister, écrire et supprimer</strong> le contenu de tous les clients. Vérifié sans aucune authentification : <code>GET firebasestorage.googleapis.com/v0/b/mymuseum-3b97f.appspot.com/o</code> répond <strong>200</strong> avec la liste des fichiers.</p>
<p><strong>Pourquoi ce n'est pas déjà fermé</strong> : le manager-app déployé écrit dans le bucket <strong>sans Firebase Auth</strong>. Fermer avant de déployer casse ses uploads. La fermeture est donc séquencée <strong>après</strong> la mise en production du lot 0 du Studio (upload par URL signée + ingestion serveur), comme le prévoit <code>v2/studio-plan.md</code> §8. ⚠️ <strong>La date de ce déploiement est donc une date de sécurité, pas seulement une date de feature</strong> — ne pas mettre la version actuelle en production sans traiter ce point dans la foulée.</p>
<p><strong>Piège à traiter dans le même geste</strong> : le CORS du bucket de prod n'a <strong>aucun <code>responseHeader</code></strong>. Le jour du déploiement, le <code>PUT</code> signé portant <code>Content-Type</code> et <code>x-goog-content-length-range</code> sera refusé au préflight et <strong>tous les uploads casseront</strong>. À corriger sur le bucket avant la bascule. Le bucket de dev <code>mymuseum-3b97f-dev</code>, lui, est déjà configuré correctement (CORS, cycle de vie <code>incoming/</code>, règles fermées) et sert de modèle.</p>
<p>Une piste applicable <strong>avant</strong> le déploiement, à vérifier : restreindre <code>read</code>/<code>list</code> seul. Les apps visiteur lisent par URL à jeton, qui ne passe pas par les règles ; reste à confirmer qu'aucun code client ne lit par le SDK Firebase.</p>
<span class="src">conversation 14/09 — relevé des règles via l'API firebaserules</span>
</article>
</div>
@ -686,7 +706,7 @@
<!-- PLANIFIÉ -->
<section class="col" style="--stripe: var(--ink-3)">
<div class="col-head"><h2>Planifié</h2><span class="count">39</span></div>
<div class="col-head"><h2>Planifié</h2><span class="count">40</span></div>
<div class="stack">
<article class="card" data-area="visitapp" data-horizon="v1">
@ -939,17 +959,13 @@
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2</span></div>
<h3>SectionForm — formulaires personnalisés</h3>
<p>Nouveau type de section + table de réponses anonymes, constructeur de formulaire et page de résultats agrégés. <strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant — rien n'oblige à le faire passer avec la migration.</p>
<span class="src">todo-features.md — SectionForm</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2</span></div>
<h3>Ressource 360° — images panoramiques</h3>
<p>Une valeur d'enum (<code>Panorama360</code>) et un branchement dans l'affichage des ressources : s'affiche partout où une ressource s'affiche, sans toucher aux sections. <strong>Reporté en V2 le 07/08</strong> — le plus petit des trois, à reprendre en premier.</p>
<span class="src">todo-features.md — Ressource 360°</span>
<div class="card-meta"><span class="tag">produit</span><span class="tag">backend</span><span class="tag">manager-app</span><span class="tag">visitapp-web</span><span class="tag">tablet-app</span><span class="flag f-warn">V2</span></div>
<h3>SectionForm — formulaires visiteurs</h3>
<p>Nouveau type de section : le lieu compose un questionnaire (texte, texte long, choix unique, choix multiple, note 1-5), le visiteur y répond <strong>anonymement</strong>, l'admin lit les réponses agrégées dans un onglet de la section, avec export CSV. Enquête de satisfaction, livre d'or, sondage d'exposition.</p>
<p><strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant. <strong>Analysé dans le code le 15/09</strong> — plan complet dans <code>v2/section-form-plan.md</code>, décisions arrêtées, ne pas refaire l'analyse.</p>
<p>Ce qui remonte aujourd'hui du visiteur est uniquement <em>dérivé</em> (<code>VisitEvent</code>, <code>VisitorQuestion</code>) : c'est le premier mécanisme qui permet à un lieu de <strong>poser une question</strong>. La moitié de la plomberie existe déjà — <code>StatsController.TrackEvent</code> pour l'écriture anonyme, <code>SectionQuiz</code>/<code>QuizQuestion</code> pour la structure, <code>VisitorQuestionPurgeService</code> pour la rétention.</p>
<p><strong>Trois fronts visiteur, kiosk compris.</strong> Piège propre à <code>tablet-app</code> : sur borne fixe le visiteur suivant hérite de l'écran du précédent — reset après soumission et sur inactivité. Et un piège backend : <code>GetEmbeddableText</code> doit indexer les libellés de questions mais <strong>jamais les réponses</strong>, sinon le guide IA récite les avis des visiteurs précédents.</p>
<span class="src">v2/section-form-plan.md — analysé le 15/09</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
@ -968,8 +984,11 @@
</article>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">Studio IA</span><span class="flag f-warn">V2 · nouveau 01/09</span></div>
<div class="card-meta"><span class="tag">Studio IA</span><span class="flag f-good">V2 · lots 0-6 codés, calibrage fait le 15/09, gate conservateur à passer</span></div>
<h3>Module Studio — génération d'images sous identité visuelle</h3>
<p><strong>15/09 : lots 5 et 6 codés, calibrage §9.4 fait.</strong> Mention IA dans le fichier (XMP IPTC) + gravure en option. Calibrage sur 61 générations (Fourneau en gravure, Fort en aquarelle) : trois défauts corrigés avant tout client — négatif en double négation, titre de fiche recopié en légende, date d'un objet effacée. Coût réel fal : 0,03 $ l'image + 0,015 $ par référence ; <strong>unité arrêtée : 1 crédit = 0,015 $ de coût</strong>, vendu avec marge (≈ 0,05 €). Planches dans <code>outputs/studio-calibrage-2026-09-15/</code>. <strong>Reste</strong> : jugement visuel de Thomas, test du conservateur (gate lot 4), push de la branche.</p>
<p>🛠️ <strong>Lots 0 à 4 codés le 13/09</strong> sur la branche <code>studio</code> (manager-service, manager-app, mymuseum-visitapp) : ingestion serveur, crédits, identité visuelle (menu <strong>Studio</strong>), génération fal.ai derrière <code>IGenerationProvider</code> (fournisseur interchangeable), onglet <strong>Générer</strong> dans le sélecteur de ressource, image d'étape rattachée à une ressource. <strong>Reste avant le gate</strong> : bucket de dev + CORS, clé fal.ai (<code>Studio__Fal__Key</code>), migrations appliquées, calibrage et prix (§9.4), puis le test du conservateur.</p>
<p><strong>Plan consolidé le 13/09.</strong> 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 : <strong>upload par URL signée + ingestion serveur</strong> (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 <code>Grant</code> manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en <strong>ED25519, pas en HMAC</strong>.</p>
<p><strong>Le produit n'est pas l'accès aux modèles, c'est la cohérence.</strong> 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 <code>VisualIdentity</code> par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + <strong>surcharge optionnelle par configuration</strong> — le parcours Halloween a sa propre identité, exactement comme <code>Configuration</code> porte déjà ses <code>PrimaryColor</code>/<code>Languages</code>. Injectée côté serveur dans chaque prompt, <strong>jamais retapée</strong>. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.</p>
<p><strong>Prérequis dur, carte séparée</strong> : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.</p>
<p>⚠️ <strong>Quatre pièges déjà tranchés.</strong> Une <strong>URL Firebase à jeton est publique</strong> — un brouillon dans <code>pictures/</code> serait servable au visiteur : préfixe <code>studio-drafts/</code> + proxy authentifié. Le <strong>quota IA existant ne tient pas</strong> sur des jobs parallèles (<code>CheckQuota</code> avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un <code>CreditLedger</code>. Le <strong>plafond dur n'arrête pas le stagiaire</strong> — c'est un plafond <em>par utilisateur et par jour</em> qu'il faut, pas un plafond organisation. Et <code>GuidedStep.ImageUrl</code> <strong>est une URL, pas un <code>ResourceId</code></strong> : les images générées de l'escape game <em>ne partiraient pas offline</em> (<code>GuidedStep.cs:74</code>) — migration à faire dans le lot MVP.</p>
@ -978,7 +997,28 @@
<p>👤 <strong>Trois entrées distinctes dans le panneau de génération</strong>, à ne pas confondre (précisé le 02/09) : l'<strong>identité visuelle</strong> donne le style du projet, l'<strong>image source</strong> donne <em>cet objet-ci</em> (la vraie pièce de la collection, avec un curseur de fidélité : rien / le sujet / sujet + pose / cadrage exact), et le <strong>personnage</strong> donne <em>cette figure récurrente</em> — « c'est Léon qui est dans le donjon ». Le canon part en référence <em>et</em> ses paramètres (époque, costume, âge) sont injectés en texte : une image de référence seule ne porte pas « capote d'officier ».</p>
<p>⚠️ <strong>Deux personnages dans une image coûtent la cohérence du style.</strong> 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 = <strong>9/10</strong>, mais deux personnages font <strong>tronquer l'identité</strong> — 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. <strong>Un personnage par image</strong>, et on compose la scène autour de lui.</p>
<p>🖼️ <strong>Attention aux personnes identifiables.</strong> « 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 <strong>rediriger vers les personnages</strong> — un avatar généré n'est le portrait de personne.</p>
<span class="src">v2/studio-plan.md — lots 0 à 10</span>
<span class="src">v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0), décisions 22-23</span>
</article>
<article class="card" data-area="manager" data-horizon="v2">
<div class="card-meta"><span class="tag">manager-app</span><span class="tag">visitapp</span><span class="tag">tablet</span><span class="tag">vr</span><span class="flag f-good">Décision arrêtée, à exécuter après la bascule</span></div>
<h3>Loader animé — presets paramétriques, pas de format de fichier</h3>
<p>Aujourd'hui le loader est une image fixe : <code>Configuration.LoaderImageUrl</code> / <code>LoaderImageId</code>, rendus par <code>loading_common.dart</code> dans les deux apps Flutter et par <code>SplashScreen</code> côté web. Le besoin : que le client compose un loader <em>animé</em> depuis le manager, sans fournir de fichier.</p>
<p><strong>Décision : pas de format de fichier animé. On transporte des paramètres.</strong> Un nouveau champ de configuration porte <code>{preset, couleurs[], logoResourceId, vitesse}</code> — quelques centaines d'octets — et chaque front implémente nativement les 5-6 mêmes presets : <code>AnimationController</code> en Flutter, CSS/Canvas en Next.js, animation Unity côté casque. Le preset n°1 existe déjà et tourne : <code>manager-app/lib/Components/loader_animated_pieces.dart</code> (6 pièces vectorielles, onde d'opacité, rotation, flottement) — il suffit d'en sortir les couleurs et les timings, aujourd'hui en dur.</p>
<p>Les couleurs sont pré-remplies depuis <code>VisualIdentityDTO.palette</code>, qui existe déjà. Aucun crédit Studio débité : le rendu est local au navigateur, rien ne passe par un modèle.</p>
<p><strong>Le SVG animé est écarté</strong> : <code>flutter_svg</code> ignore <code>&lt;animate&gt;</code>, SMIL et les keyframes CSS — il rendrait une image figée dans les trois apps Flutter — et Unity n'a aucun rendu SVG. Un seul des quatre fronts l'afficherait.</p>
<p><strong>Lottie est écarté à ce stade</strong>, malgré son écosystème. Format de fait et non norme, gouverné par une société unique (LottieFiles) sur une spec récente ; surtout, <em>aucun runtime moteur de jeu n'est listé sur le site officiel</em>. Les deux options Unity sont fragiles : <code>thorvg.unity</code> (Android arm64 annoncé, mais 26 commits et 14 étoiles, successeur de <code>Lottity</code> archivé en 11/2025) et <code>unity-rlottie</code> (plus fourni mais en <em>experimental</em>, avec un ticket ouvert sur une texture NULL en build Android). Les deux rastérisent en <code>Texture2D</code> à chaque frame côté CPU — inacceptable sur Quest à 72-90 Hz pour un écran de chargement.</p>
<p><strong>Porte de sortie assumée</strong> : le jour où un client veut apporter <em>sa</em> propre animation faite par une agence, on ajoute Lottie sur les 3 fronts 2D seulement, avec le PNG de première frame en repli côté VR. Le champ loader accepte alors soit des paramètres, soit une URL — rien de ce qui est fait ici n'est à refaire.</p>
<p><strong>Coût réel : les presets.</strong> Chacun est du code dans 4 dépôts, à tester sur les 4 fronts. En sortir 5-6, pas 20 : au-delà le client ne choisit plus, il se perd. Stocker les paramètres permet la réédition six mois plus tard.</p>
<p>⚠️ <strong>Pas un bloquant V1</strong> — le <code>loaderImageUrl</code> actuel fait le travail avec un PNG. À ouvrir après la bascule prod.</p>
<span class="src">décision 2026-09-15 — analyse des formats de loader animé</span>
</article>
<article class="card" data-area="manager" data-horizon="v1">
@ -1032,18 +1072,38 @@
<span class="src">DOCS/claude design/refonte-configuration-sections.html</span>
</article>
<article class="card" data-area="backend" data-horizon="v2">
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">Studio IA</span><span class="flag f-critical">Prérequis dur</span></div>
<h3>Socle de stockage serveur — le backend ne sait pas écrire dans le bucket</h3>
<p><code>IResourceBlobService</code> n'expose que <code>DeleteAsync</code> : <strong>tout l'upload est fait par le navigateur en direct</strong> (<code>resources_screen.dart:247-256</code>). Or le Studio produit ses images côté serveur, et le TTS pré-généré aussi. Il faut <code>UploadAsync</code>, <code>CopyAsync</code>, <code>ProbeSizeAsync</code> — le credential est <em>déjà</em> chargé pour FCM, seul <code>Firebase:StorageBucket</code> est vide en config (carte dédiée en colonne Bascule prod).</p>
<p>⚠️ <strong><code>ImageHelper</code> est du code mort</strong> : <code>ResourceController.Upload</code> (base64 + resize + watermark) s'appuie sur <code>System.Drawing.Common</code>, qui ne tourne pas sur l'image <code>aspnet:8.0</code> Linux. À réécrire en <strong>ImageSharp</strong> — c'est aussi ce qui portera la compression serveur 2560&nbsp;px / q82 et l'option de gravure du watermark.</p>
<p><strong>Un lot, deux modules servis.</strong> Le TTS pré-généré attend exactement la même chose (<code>tts-pregenerated-plan.md</code> : « upload serveur via Firebase Admin SDK »). Le faire une fois, proprement, débloque les deux. À caser avec l'ajout de <strong><code>LB</code> (luxembourgeois)</strong> aux langues supportées — absent des 4 repos, zéro occurrence, et bloquant pour le projet luxembourgeois en cours indépendamment du Studio.</p>
<span class="src">v2/studio-plan.md — lot 0 · v2/media-storage-plan.md</span>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">manager-app</span><span class="tag">Studio IA</span><span class="tag">sécurité</span><span class="flag f-warn">Code fait le 13/09, infra à faire</span></div>
<h3>Socle de stockage — URL d'envoi signée et ingestion serveur</h3>
<p>🛠️ <strong>Code livré le 13/09</strong> (branche <code>studio</code>) : URL signée, <code>ResourceIngestionService</code>, ImageSharp, upload avec progression dans manager-app, SDK Firebase retiré. <strong>Reste l'infra</strong> ci-dessous, puis la checklist du §8.</p>
<p><code>IResourceBlobService</code> n'expose que <code>DeleteAsync</code> : <strong>tout l'upload est fait par le navigateur en direct</strong>, à deux endroits de <code>resources_screen.dart</code>. Or le Studio et le TTS pré-généré produiront côté serveur.</p>
<p>⚠️ <strong>Trou de sécurité actuel, indépendant du Studio.</strong> manager-app n'a pas de Firebase Auth (<code>firebase_storage</code> sans <code>firebase_auth</code>) et écrit pourtant dans le bucket : les règles Storage sont très probablement <strong>ouvertes en écriture</strong>. Aucun <code>storage.rules</code> dans le repo — à relever dans la console.</p>
<p><strong>Tranché le 13/09 : URL signée + ingestion.</strong> L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans <code>incoming/</code>, puis <code>POST /ingest</code> : un <code>ResourceIngestionService</code> unique — le même pour le Studio et le TTS — mesure la <strong>taille réelle</strong>, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous <code>pictures/</code>. 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.</p>
<p>Infra : CORS du bucket pour le <code>PUT</code>, règle de cycle de vie à 1 jour sur <code>incoming/</code>, et fermeture des règles <strong>après</strong> le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route <code>upload</code> morte et d'<code>ImageHelper</code>, <code>Firebase:StorageBucket</code> renseigné, et <strong><code>LB</code> (luxembourgeois)</strong>.</p>
<span class="src">v2/studio-plan.md — §8 lot 0, décisions 14, 18, 20</span>
</article>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">manager-service</span><span class="tag">Médiathèque</span><span class="flag f-warn">IsImageWatermark activé ne filigrane rien</span></div>
<h3>Le watermark des images est inactif depuis l'upload direct</h3>
<p><strong>Constat du 13/09.</strong> Le seul code de watermark vivait dans <code>ResourceController.Upload</code> (base64, <code>System.Drawing</code>) : il écrivait le texte <strong>« fortsaintheribert.be » en dur</strong>, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, <strong>aucune image n'est filigranée</strong>, même sur une instance où <code>Instance.IsImageWatermark</code> est activé.</p>
<p>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.</p>
<p><strong>À trancher avant de le refaire</strong>, dans le <code>ResourceIngestionService</code> du lot 0 (le seul endroit qui voit passer toutes les images) :</p>
<ul>
<li>le Fort le veut-il encore ?</li>
<li>texte ou logo, et <strong>par instance</strong> au lieu d'un nom de domaine en dur ;</li>
<li>une police embarquée dans le repo — Arial n'existe pas dans le conteneur <code>aspnet:8.0</code> Linux — via <code>SixLabors.ImageSharp.Drawing</code> ;</li>
<li>jamais sur une <code>Image360</code>, comme la compression.</li>
</ul>
<p>Distinct de la gravure « générée par IA » du Studio (<code>Instance.IsAiWatermarkBurned</code>, décision 6), qui réutilisera le même chemin.</p>
<span class="src">conversation 13/09 — lot 0 du Studio (studio-plan.md §8)</span>
</article>
<article class="card" data-area="visitapp backend" data-horizon="v2">
<div class="card-meta"><span class="tag">3 repos</span><span class="tag">Studio IA</span><span class="flag f-warn">V2 · remplace le talking head</span></div>
<div class="card-meta"><span class="tag">3 repos</span><span class="tag">Studio IA</span><span class="flag f-good">V2 · lots 8 et 8bis codés le 15/09, à tester</span></div>
<h3>Personnages — un objet, quatre facettes</h3>
<p><strong>Lot 8 et 8bis codés le 15/09, à tester</strong> (plan : <code>test-plan-studio-narration.md</code>). Narrateurs assignables (étape, point, article ; défaut parcours, carte, visite ; guide par visite) avec la chaîne de § 3.8 ; génération audio Gemini TTS → MP3, 1 crédit par minute, état « à régénérer » sur changement de texte ou de voix ; onglet Narrateurs du parcours, blocs sur l'article et le point, carte Narration et casting dans l'écran Configuration ; portrait du narrateur à côté du lecteur et écran « les personnes que vous allez rencontrer » sur les trois apps visiteur. <strong>Reste</strong> : les extraits d'écoute des voix (clé Gemini). Le lecteur audio d'un point de carte côté visiteur a été ajouté au lot (8e).</p>
<p>🛠️ <strong>Lot 7 en cours (15/09).</strong> Entité <code>Persona</code>, écran Personnages, <code>TtsVoice</code> en base, migration des colonnes <code>Instance.Guide*</code> faits. Figures des 12 archétypes générées et embarquées. <strong>Reste</strong> : l'écoute des voix (échantillons audio à produire, pas de clé Gemini en local), puis le lot 8 (narration attribuée).</p>
<p><strong>Trois plans décrivaient le même objet sans se croiser</strong> (relevé le 01-02/09) : le canon visuel (<code>studio-plan.md</code>), les 3 frames de lipsync (<code>talking-head-plan.md</code>) et <code>PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }</code> (<code>tts-pregenerated-plan.md</code>). Une entité <code>Persona</code> les remplace : identité, visage (canon en <em>jeu de vues</em>), voix + intonation, parole.</p>
<p><strong>Le talking head est abandonné, sa dépendance était déjà cassée.</strong> <code>talking-head-plan.md:11</code> anime ses frames « selon les timestamps retournés par <strong>Google Cloud TTS</strong> » (<code>enable_time_pointing</code>) — or le code livré tourne sur <strong>Gemini TTS</strong> (<code>GeminiTtsEngine</code>, <code>gemini-2.5-flash-preview-tts</code>) : aucune source de timestamps. Remplacé par le <strong>portrait canon statique</strong> à côté du lecteur audio, puis une boucle vidéo 6-8 s si du mouvement est voulu. En-tête d'abandon posé sur le fichier.</p>
<p>⚠️ <strong>« Le visiteur choisit entre Viva et Marco » était un choix de voix déguisé en choix de guide.</strong> Le besoin réel est celui du <strong>Bastogne War Museum</strong> : 3-4 personnages qui narrent chacun <em>leurs</em> stations, avec leur voix et leur intonation. Le conservateur assigne (<code>NarratorPersonaId</code> sur <code>GuidedStep</code>/<code>GeoPoint</code>/<code>SectionArticle</code>), le visiteur ne choisit rien. <strong>Bonne nouvelle : ça divise le stockage TTS par deux au lieu de le multiplier</strong> — chaque contenu n'existe plus qu'en une voix.</p>
@ -1057,13 +1117,29 @@
<span class="src">v2/studio-plan.md §3.8 — lots 7 et 8</span>
</article>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">XR</span><span class="flag f-warn">V2 · nouveau 31/08</span></div>
<h3>Back-office XR — <code>Device.AppType</code> + onglet flotte de casques</h3>
<p>⚠️ <strong>« Zéro ligne de code » était faux</strong> — relevé dans le code le 31/08. Le canal VR est déjà dans le modèle : <code>AppType.VR</code> (valeur 3) côté C# <em>et</em> dans <code>manager_api_new</code>, <code>Instance.IsVR</code> en base depuis la migration de juillet 2025, sous-menu « VR » branché dans <code>main_screen.dart:65</code>, <code>AppConfigurationLink.DeviceId</code> (le mécanisme « une config par appareil » du kiosk), filtre stats et i18n <code>statsChannelVR</code> prêts.</p>
<p><strong>Deux trous seulement.</strong> <code>main_screen.dart:704</code> est littéralement un <code>Text("TODO vr")</code> ; et <code>DeviceController.Create</code> est <strong>hardcodé sur <code>AppType.Tablet</code></strong> (<code>DeviceController.cs:155</code>), donc un casque enregistré aujourd'hui atterrirait dans l'onglet Kiosk. Il faut un <code>Device.AppType</code> (défaut <code>Tablet</code>, l'existant ne bouge pas) et <code>ApiKeyAppType.VrApp</code> <em>en fin d'enum</em> — il est persisté en int.</p>
<p>L'écran est un <strong>clone de <code>Kiosk_devices/</code></strong> (4 fichiers, 769 l.) : grille de casques, pincode d'appairage, assignation d'une configuration par appareil, plus batterie / <code>AppVersion</code> / <code>LastSeen</code> — colonnes déjà en base, jamais affichées pour le kiosk. <strong>Estimé 8-12 j au total</strong> (backend 2-3 j, écran 4-5 j, contrat de contenu 1-2 j), et ce lot <strong>se tient debout tout seul</strong> : il rend le canal administrable et démontrable avant tout engagement sur Unity.</p>
<span class="src">v2/vr-quest-unity-plan.md §1, §2 (lots V-1 à V-3)</span>
<article class="card" data-area="vr backend" data-horizon="v2">
<div class="card-meta"><span class="tag">VR</span><span class="tag">Studio IA</span><span class="flag f-warn">V2 · après le test du lot 8 sur les trois apps, avec le casque</span></div>
<h3>Studio dans l'app VR — mention IA, narration, casting (lot 8f)</h3>
<p><strong>Le lot 8 (narrateurs, audio généré, portrait, casting) a été livré sur les trois apps visiteur — web, mobile, tablette — mais pas sur l'app Quest</strong>, qui lit pourtant le contenu par l'export de configuration (<code>ConfigurationExport.cs</code>). Relevé le 2026-09-15.</p>
<p><strong>Ce que fait la VR aujourd'hui</strong> : Map et Parcours rendus en liste de points, sans lire <code>audioIds</code> ; hotspots de maquette 3D dont l'audio vient des <em>contenus</em> du point, pas de l'audio par langue ; ni portrait du narrateur, ni écran « les personnes que vous allez rencontrer ».</p>
<p><strong>À faire</strong> :</p>
<ol>
<li>Export Unity : <code>audioIds</code> des points et des étapes, <code>narratorName</code> / <code>narratorPortraitUrl</code> sur la ressource, <code>casting</code> de la configuration.</li>
<li>Lecture de l'audio narré d'un point et d'une étape, portrait à côté (audio spatialisé sur un hotspot).</li>
<li>Serveur : rendre narrables les points d'une maquette 3D — la section maquette comme conteneur dans la chaîne de <code>NarratorResolver</code> (aujourd'hui, un point sans <code>SectionMapId</code> est refusé par <code>NarrationService</code>).</li>
<li>Écran d'entrée du casting au lancement de la visite.</li>
</ol>
<p>🔎 <strong>Repasse du 2026-09-15 sur les lots Studio 0 à 8</strong> (code de <code>vr-app</code> relu) — ce qui touche le visiteur et manque au casque :</p>
<ul>
<li><strong>Lot 6, mention « image générée par IA » — manquante.</strong> <code>ConfigurationExport.Resource</code> ne lit pas <code>origin</code> : les images d'un Slider, la photo d'un point et les médias d'un hotspot s'affichent sans mention. <code>ProvenanceManifest.AiGenerated</code> existe dans le manifeste mais rien ne le remplit depuis l'export. À ajouter avec le reste — c'est l'obligation d'information de l'AI Act, pas un confort.</li>
<li><strong>Lot 8, audio narré — manquant</strong> (points 1 et 2 ci-dessus). Le commentaire d'un hotspot vient de ses <em>contenus</em> ; la narration écrit dans <code>audioIds</code>, que le casque ne lit pas.</li>
<li><strong>Lot 8bis, casting — manquant</strong> (point 4).</li>
<li><strong>Lot 7, personnages — couture prévue, pas faite.</strong> <code>ScenePersona.PersonaId</code> est « null en V1, la couture vers l'entité <code>Persona</code> » : les personnages GLB d'une scène ne sont pas reliés aux fiches Personnages (nom, portrait, voix). Relève plutôt du lot 11 (3D).</li>
<li><strong>Sans objet</strong> : lots 0 à 5 (serveur et manager), crédits et calibrage. Le correctif « audio rangé par identifiant » de 8d ne s'applique pas : le casque résout déjà ses médias par identifiant via <code>FindResource</code>.</li>
<li><strong>Hors Studio, vu en passant</strong> : un <strong>Parcours</strong> est rendu « comme une Map » à partir de <code>section.Points</code>, or ses étapes vivent dans <code>guidedPaths</code> — un Parcours s'ouvre probablement vide sur le casque. À vérifier casque en main.</li>
</ul>
<p><strong>Pas avant</strong> : que le plan de test <code>test-plan-studio-narration.md</code> soit passé sur les trois autres apps, et avec le casque en main — rien de ceci ne se vérifie sans l'éditeur Unity. Le dépôt <code>vr-app</code> n'a toujours ni remote ni commit.</p>
<span class="src">v2/studio-plan.md — lot 8f</span>
</article>
<article class="card" data-area="produit" data-horizon="v2">
@ -1078,14 +1154,33 @@
</article>
<article class="card" data-area="doc" data-horizon="v2">
<div class="card-meta"><span class="tag">XR</span><span class="flag f-warn">V2 · subventionné · go/no-go</span></div>
<div class="card-meta"><span class="tag">XR</span><span class="flag f-warn">V2 · POC écrit en entier, rien n'a jamais tourné</span></div>
<h3>Meta Quest — app Unity + Meta XR SDK</h3>
<p>Stack tranchée le 31/08 : <strong>Unity 6 LTS + OpenXR + Meta XR feature group</strong>, pas React Native. Le contenu se récupère par <code>GET /api/configuration/{id}/export</code> — un seul appel qui renvoie configuration + sections + ressources, donc pas de DTO C# à réécrire endpoint par endpoint. <strong>POC 3-4 semaines, couverture complète 6-10 semaines</strong>, + 1-2 sem. de supervision de flotte (le publish MQTT serveur existe déjà, <code>DeviceController.cs:303</code>).</p>
<p>⚠️ <strong>Le risque est produit, pas technique.</strong> Le CMS est 2D : un Article sur un panneau flottant est <em>moins</em> bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent — et la <strong>ressource 360° est un prérequis dur</strong> (carte dédiée dans cette colonne). <strong>Go/no-go conditionné à un client pilote disposant déjà de vidéos 360°</strong> ; sans lui, le POC démontrera une régression.</p>
<p><strong>Unity n'est pas une nécessité technique</strong> (tranché le 31/08) : le mode kiosk vient de la <em>gestion d'appareil</em>, pas du moteur — un MDM verrouille n'importe quelle app installée, y compris un navigateur épinglé. Unity gagne sur l'<strong>exploitation sans surveillance</strong> : cache offline multi-Go, décodage 8K, et surtout un build <em>figé</em> là où le navigateur du casque s'auto-met à jour et peut casser la borne. Contre-argument sérieux à garder : <code>visitapp-web</code> rend déjà les 13 types en React, donc une couche WebXR attaquerait le risque « quatrième front à maintenir ». <strong>Retenu : WebXR pour la maquette, Unity pour la borne</strong> — à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».</p>
<p>⚠️ <strong>MDM rétrogradé le 31/08</strong> : inutile à 1-3 casques (sideload + mode appareil suffisent), ne redevient un sujet qu'au-delà d'une dizaine.</p>
<p>⚠️ <strong>Les Ray-Ban ne sont plus dans cette carte : elles sont passées en V1 le 12/08</strong>, et leur volet stats est livré depuis le 13/08 (voir « Fait récemment »).</p>
<span class="src">v2/vr-quest-unity-plan.md §2 (lots V-4, V-5), §4, §5 · roadmap.md — section XR</span>
<p><strong>Stack tranchée le 31/08</strong> : Unity 6 LTS + OpenXR + Meta XR feature group. Le contenu se récupère par <code>GET /api/configuration/{id}/export</code> — un seul appel qui renvoie configuration, sections et ressources, donc aucun DTO C# à réécrire endpoint par endpoint. <strong>Unity n'est pas une nécessité technique</strong> : 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 <em>figé</em> là où le navigateur du casque s'auto-mettrait à jour et pourrait casser la borne un mardi matin. <strong>Retenu : WebXR pour la maquette, Unity pour la borne</strong>, à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».</p>
<p><strong>Le POC est écrit en entier — E0 à E10, entre le 11 et le 12/09.</strong> Unity 6000.0.83f1, projet URP dans <code>vr-app/</code>, 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.</p>
<table>
<tr><th>Item</th><th>Ce qui est écrit</th></tr>
<tr><td><strong>E2-E3</strong></td><td>Appairage par code PIN (<code>PairingService</code>) et lecture de l'export. ⚠️ Un <strong>404 sur <code>POST /api/device</code></strong> ne veut pas dire « introuvable » : il n'y a pas d'<code>ApplicationInstance</code> VR sur l'instance.</td></tr>
<tr><td><strong>E4</strong></td><td><strong>Cache d'abord</strong> : 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.</td></tr>
<tr><td><strong>E5</strong></td><td>Menu flottant en arc à 2,2 m, qui <strong>ne suit pas la tête</strong>. <strong>Trois moyens de viser</strong> : manette (gâchette), main (pincement), tête (1,2 s, contre le « Midas touch »). ⚠️ <strong>« À la tête », pas « aux yeux »</strong> — le Quest 2 n'a pas d'eye tracking. ⚠️ La dépendance <code>E5 → E1</code> du plan <strong>était fausse</strong> : <code>OVRInput</code> et <code>OVRHand</code> viennent du Core SDK, donc <strong>E1 sort du chemin critique</strong>.</td></tr>
<tr><td><strong>E6</strong></td><td>Image et vidéo 360° en skybox. Le <strong>retour au menu ne reste pas planté dans le décor</strong> : 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 <strong>invisibles dans l'éditeur</strong> : <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em> (sinon ciel magenta sur le casque seul), et une équirectangulaire décodée pèse <strong>134 Mo</strong> — compressée en ASTC et libérée à la sortie, sinon l'app meurt après quelques 360.</td></tr>
<tr><td><strong>E8</strong></td><td>Slider, Map, Parcours et Event, dans une même vue paginée. ⚠️ <strong>Deux écarts assumés</strong> : la Map est une <strong>liste de POI</strong>, pas la maquette 3D promise (elle demande <code>SectionModel3D</code>) ; le Parcours est rendu comme une Map, pour la même raison.</td></tr>
<tr><td><strong>E9</strong></td><td>Télémétrie « tire et oublie ». ⚠️ Route <code>/api/stats/event</code>, et <code>appType</code> y part en <strong>chaîne parsée par nom</strong> : une faute de frappe retombe sur Mobile <em>sans erreur</em>.</td></tr>
<tr><td><strong>E10</strong></td><td>Fin de visite à la repose du casque ou après 90 s, menu <strong>recentré devant le visiteur suivant</strong>, nouvelle session de stats. ⚠️ Le verrouillage de l'app sur le casque n'est pas du code : c'est le mode appareil de Meta.</td></tr>
</table>
<p>⚠️ <strong>Rien de E2 à E10 n'a jamais tourné</strong> — ni contre un vrai serveur, ni sur le casque. Le code compile, c'est tout ce qu'on sait. <strong>Plan de test §25</strong>, 9 blocs, écrit pour ça ; son §25.7 (déclarer une 360 dans le manager) se joue <strong>sans casque</strong>.</p>
<p>⚠️ <strong>Le risque est produit, pas technique, et il est intact.</strong> Le CMS est 2D : un Article sur un panneau flottant est <em>moins</em> bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent. <strong>Go/no-go conditionné à un client pilote disposant déjà de vidéos 360°</strong> — rien de ce qui a été codé ne répond à cette question-là.</p>
<p>🟡 <strong>XR-5, la supervision de flotte : l'essentiel est écrit</strong> (12/09). Un <strong>battement HTTP</strong> toutes les 3 min remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR, qui affichaient « — » faute d'alimentation — <code>Update</code> n'écrivait ni <code>AppVersion</code> ni <code>LastSeen</code>. Pas de MQTT : à 1-3 casques il coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour <strong>pousser</strong> vers le casque, pas pour remonter. <strong>Reste</strong> : le client MQTT le jour où l'on voudra recharger une configuration à distance, et une alerte quand un casque ne donne plus signe.</p>
<p>🟡 <strong>E7, la maquette 3D, est écrit aussi</strong> (12/09). <code>SectionModel3D</code> est <strong>un vrai type de section</strong> — une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres — mais il réutilise le <code>GeoPoint</code>, qui portait déjà deux rattachements : les points gardent titre, description, audio et multilingue. ✅ <strong>L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial du lot, n'était pas à écrire</strong> : <code>vr-app/viewer</code> sait déjà le faire et <strong>son protocole <code>postMessage</code> existait déjà</strong>, 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.</p>
<p><strong>Reste au lot</strong> : <code>E11</code> (distribution : Horizon Store ou canal privé, à revérifier avant toute offre). ⚠️ <strong>MDM rétrogradé le 31/08</strong> : inutile à 1-3 casques, sideload + mode appareil suffisent.</p>
<span class="src">v2/vr-quest-unity-plan.md §2 (lots XR-4, XR-5), §4, §5 · roadmap.md — section XR</span>
</article>
<article class="card" data-area="commercial" data-horizon="v2">
@ -1543,11 +1638,45 @@
<span>⚠️ <strong>Deux pièges tranchés en écrivant</strong> : la montée v4 laisse les dates existantes à <code>NULL</code> et les considère <strong>à jour</strong> (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 <strong>écrasait la date</strong> que la boucle de téléchargement venait d'écrire — <code>DatabaseHelper.insert</code> fait un UPDATE de la ligne entière quand l'id existe.</span>
</div>
<div class="done-item">
<strong>Back-office XR — le canal VR est administrable</strong>
<p><strong>Clos le 12/09.</strong> Le chantier tenait en trois lots, et les trois sont retombés : <strong>XR-1</strong> le 11/09 (<code>Device.AppType</code>, migration <code>20260911135527_AddAppTypeToDevice</code>, <code>DeviceController.Create</code> qui résout l'<code>ApplicationInstance</code> sur <code>appType</code>, <code>ApiKeyAppType.VrApp</code> en fin d'enum, 224 tests au vert), <strong>XR-3</strong> qui était déjà fait sans que le plan le sache (export ouvert aux apps depuis <code>9cc45c5</code>), et <strong>XR-2</strong> le 12/09.</p>
<p>L'écran XR est une coquille à <strong>deux sous-onglets</strong> : « Configuration », qui réutilise <code>AppConfigurationLinkScreen</code> tel quel — il est déjà générique sur l'<code>appType</code>, zéro code neuf —, et « Casques », la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL.</p>
<p>⚠️ <strong>Deux affirmations du plan étaient fausses, relevées dans le code.</strong> La grille kiosk ne liste pas des <code>Device</code> mais des <code>AppConfigurationLink</code> : le filtre par canal était déjà implicite, et le paramètre <code>appType</code> de <code>/api/device</code> ajouté par XR-1 ne sert pas à cet écran. Et batterie / version / dernier vu ne sont pas dans <code>DeviceDTO</code>, seulement dans <code>DeviceDetailDTO</code> — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités.</p>
<p>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 <strong>XR-4, l'app Unity</strong> — carte séparée.</p>
</div>
<div class="done-item">
<strong>Trois plans écrits, deux écrans maquettés</strong>
<span>Médias &amp; 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. <strong>Les deux écrans maquettés sont désormais implémentés.</strong></span>
</div>
<div class="done-item">
<strong>Ressource 360° — et la lecture immersive qu'elle débloque</strong>
<p><strong>Clos le 12/09.</strong> Trois valeurs ajoutées <strong>en fin</strong> de <code>ResourceType</code><code>Image360</code> (11), <code>Video360</code> (12), <code>Model3D</code> (13) — <strong>sans migration</strong> : la colonne est déjà <code>integer</code>, 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.</p>
<p>La carte disait « une valeur d'enum et un branchement dans l'affichage ». <strong>Il manquait les deux morceaux qui comptent.</strong></p>
<p><strong>Un : comment on déclare une 360.</strong> Aucune extension ne le dit — un panorama est un <code>.jpg</code> comme un autre. La pastille de type du sélecteur de médias devient donc <strong>cliquable</strong> : elle bascule Image ↔ Image 360°, Vidéo ↔ Vidéo 360°, et se colore quand c'est immersif. Le <code>.glb</code>, lui, se déduit seul : un GLB n'est jamais autre chose qu'un modèle 3D.</p>
<p><strong>Deux, et c'est le vrai piège : la compression.</strong> <code>ImageCompressor</code> 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 <strong>deux</strong> chemins d'upload, qui avaient déjà divergé deux fois par le passé.</p>
<p><strong>Bonne surprise</strong> : le prérequis « le backend ne sait pas écrire dans le bucket », qui bloque le Studio et le TTS, <strong>ne s'applique pas ici</strong><code>manager-app</code> pousse directement dans Firebase Storage puis enregistre l'URL. L'ancienne route serveur <code>/upload</code> plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.</p>
<p>➡️ <strong>Ce que ça débloque</strong> : <code>E6</code> du lot XR-4, la lecture 360° dans le casque, écrite dans la foulée (<code>SkyboxView</code>, image et vidéo dans le même shader panoramique). C'était le dernier prérequis du POC VR. ⚠️ <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em>, sinon le ciel sort magenta sur le casque et correct dans l'éditeur. Plan de test §25.7 — sa première moitié se joue <strong>sans casque</strong>.</p>
<p><strong>Non fait, et assumé</strong> : l'exploitation du <code>Model3D</code>. 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 <code>E7</code>, et le §9 du plan VR le décrit.</p>
</div>
<div class="done-item">
<strong>L'appairage d'une nouvelle tablette répondait 403 depuis mars</strong>
<p><strong>Trouvé et corrigé le 12/09</strong>, en écrivant l'appairage du casque : il butait sur le même mur.</p>
<p><code>DeviceController</code> porte <code>[Authorize(InstanceAdmin)]</code> <strong>sur la classe</strong>, et <code>Create</code> n'avait aucune exception. Or une clé d'API ne porte que <code>AppRead</code> et <code>Viewer</code> (<code>ApiKeyAuthenticationHandler</code>), et <code>tablet-app</code> <strong>ne s'authentifie jamais autrement</strong> — aucun appel d'authentification dans tout le repo. <code>POST /api/device</code> répondait donc <strong>403 à toute tablette</strong>.</p>
<p><strong>Depuis quand</strong> : commit <code>a452f4a</code> du <strong>13/03/2026</strong>, dont le message dit lui-même « need to be tested ». <strong>Pourquoi personne ne l'a vu</strong> : une tablette déjà appairée ne rappelle jamais <code>Create</code>. Le parc existant continuait de fonctionner ; seul un <em>nouvel</em> appareil échouait — c'est-à-dire exactement ce qu'on ne fait pas tous les jours.</p>
<p><strong>Correctif</strong> : <code>Create</code> passe en <code>[AllowAnonymous]</code> + <code>[RequireAppKey]</code>, 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 <strong>son</strong> instance.</p>
🔴 <strong>Et ce n'était pas que l'appairage.</strong> En auditant les clients le 12/09, deux autres maillons du même mur :</p>
<ul>
<li><code>GET /api/Device/{id}/detail</code> était fermé aux clés lui aussi — or c'est le <strong>premier appel d'une tablette qui démarre</strong> (<code>tablet-app/lib/main.dart:46</code>) : celui qui lui dit quelle configuration afficher. Une tablette <em>déjà appairée</em> ne retrouvait donc plus son contenu au redémarrage. Ouvert de la même façon.</li>
<li><code>tablet-app/lib/main.dart:38</code> reconstruisait son client <strong>sans clé</strong><code>Client(host)</code> au lieu de <code>Client(host, apiKey: …)</code> — alors que la clé <em>est</em> persistée en base locale (<code>TabletAppContext.toMap</code>). Une ligne, et tous les appels d'après partaient anonymes.</li>
</ul>
<p>Les deux se tenaient : la route refusait les clés, et l'app n'en envoyait pas. Corrigés ensemble.</p>
<p>⚠️ <strong>À vérifier sur le terrain</strong> : appairer une <em>vraie</em> nouvelle tablette, <strong>et redémarrer une tablette déjà appairée</strong>. Le correctif est couvert par les tests, mais les tests appellent le contrôleur directement — <strong>aucun filtre d'autorisation ne s'y exécute</strong>, donc ils ne prouvent rien sur l'accès lui-même.</p>
</div>
</div>
</section>

View File

@ -3,12 +3,18 @@ title: L'API publique répond <strong>sans clé API</strong>
area: backend
tags: manager-service, sécurité
flag: critical | Le contenu de n'importe quelle instance est lisible par qui connaît son identifiant
src: conversation 08/09 — vérification de la préprod depuis internet
src: conversation 08/09 — vérification de la préprod depuis internet · audit complet 12/09
---
<p>Constaté le 08/09 sur la préprod, depuis internet, sans authentification :</p>
<pre>curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047"
→ 200, avec le contenu complet et ses traductions</pre>
<p>La même requête <strong>avec</strong> <code>X-Api-Key</code> renvoie le même 200. <strong>Cause exacte, vérifiée le 08/09</strong> : <code>Get</code> (<code>ConfigurationController.cs:52</code>) et <code>GetDetailAsync</code> (<code>:117</code>) portent un <code>[AllowAnonymous]</code> <em>explicite</em>. 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 (<code>client.dart:54</code>), et le mécanisme existe et fonctionne : <code>ApiKeyAuthenticationHandler</code> + la policy <code>AppReadAccess</code>, déjà utilisée par <code>byPin</code> (<code>:83</code>) et <code>export</code> (<code>:383</code>) du même contrôleur.</p>
<p><strong>Ce que ça expose</strong> : le contenu éditorial de n'importe quelle instance, à qui connaît un <code>instanceId</code> — 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.</p>
<p>⚠️ <strong>À vérifier avant de conclure</strong> : le périmètre exact. J'ai testé <code>/api/Configuration</code> ; il faut passer en revue les autres routes que consomment les apps visiteur (<code>/api/Section/configuration/{id}</code>, <code>/api/Resource/{id}</code>, <code>/api/SectionMap</code>, <code>/api/SectionQuiz</code>) avant de savoir si le trou est ponctuel ou général.</p>
<p>C'est le problème symétrique du 403 sur <code>GET /api/Instance/{id}</code>, <strong>corrigé le 08/09</strong> (voir la carte close) : la même app est <strong>trop bloquée</strong> sur une route et <strong>pas bloquée du tout</strong> sur les autres. Les deux se traitent ensemble, en décidant ce que <code>X-Api-Key</code> est censé garder.</p>
<p><strong>Audit complet fait le 12/09</strong><strong>40 routes anonymes</strong> recensées, dont <strong>2 seulement contrôlent la clé</strong> (<code>Configuration/{id}/export</code> et <code>Instance/{id}</code>, tous deux corrigés en septembre). Le trou est donc <strong>général, pas ponctuel</strong> : 24 routes de contenu sont ouvertes — <code>Configuration</code> (2), <code>Section</code> (4), <code>Resource</code> (2), <code>SectionMap</code> (3), <code>SectionEvent</code> (3), <code>SectionAgenda</code> (2), <code>SectionParcours</code> (2), <code>SectionQuiz</code> (1), <code>ApplicationInstance</code> (2).</p>
<p>🔴 <strong>Trouvaille plus grave que la carte, corrigée le 12/09.</strong> <code>GET /api/Instance/slug/{slug}</code> était anonyme <strong>et sans filtrage</strong> : il rendait le <strong>pinCode</strong> — celui qui ouvre l'appairage des tablettes et des casques — plus l'<strong>adresse de facturation</strong>, le <strong>numéro de TVA</strong>, les quotas et le plan du client. Et le slug n'est pas un secret : <strong>c'est l'URL du site visiteur</strong> (<code>app.myinfomate.be/{slug}</code>). La fonction de filtrage <code>StripCommercialFields</code> existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur <code>slug</code> et sur <code>byPin</code>, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : <code>publicApiKey</code>, sans laquelle <code>visitapp-web</code> ne peut pas s'amorcer, et <code>isTrialActive</code>, qui sert au filigrane d'essai affiché au visiteur.</p>
<p>⚠️ <strong>Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas.</strong> La clé s'obtient <strong>anonymement</strong> : par le slug (qui est dans l'URL) ou par <code>app-key</code> 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 <code>instanceId</code>. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et <strong>révocable</strong>, 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.</p>
<p><strong>Reste à faire</strong> : fermer les 24 routes de contenu. Deux obstacles connus. <strong>Un</strong> : <code>[Authorize]</code> de classe et d'action se <strong>combinent</strong> — c'est pour ça qu'<code>export</code> passe par <code>[AllowAnonymous]</code> + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. <strong>Deux</strong> : deux routes doivent <strong>rester</strong> anonymes et c'est écrit dans le code de <code>visitapp-web</code><code>instance/slug/{slug}</code> (l'amorce, qui distribue la clé) et <code>ApplicationInstance?instanceId=</code> (la page <code>/download</code>, où le visiteur arrive d'un QR imprimé sans slug ni clé).</p>

View File

@ -0,0 +1,15 @@
---
title: Le bucket Firebase est <strong>ouvert en écriture et en suppression à tous</strong>
area: backend infra
tags: manager-service, sécurité, Studio
flag: critical | À régler impérativement avant/avec la mise en production de la version actuelle
src: conversation 14/09 — relevé des règles via l'API firebaserules
---
<p>Relevé le 14/09 sur le projet <code>mymuseum-3b97f</code> (release <code>firebase.storage/mymuseum-3b97f.appspot.com</code>, ruleset <code>025f98db-…</code>, <strong>inchangé depuis le 08/01/2024</strong>) :</p>
<pre>match /{allPaths=**} {
allow read, write; //: if request.time &lt; timestamp.date(2024, 1, 12)
}</pre>
<p>La garde temporelle du template Firebase a été <strong>commentée</strong> pour qu'elle n'expire pas. N'importe qui connaissant le nom du bucket — il est dans l'URL de chaque image, donc public — peut <strong>lire, lister, écrire et supprimer</strong> le contenu de tous les clients. Vérifié sans aucune authentification : <code>GET firebasestorage.googleapis.com/v0/b/mymuseum-3b97f.appspot.com/o</code> répond <strong>200</strong> avec la liste des fichiers.</p>
<p><strong>Pourquoi ce n'est pas déjà fermé</strong> : le manager-app déployé écrit dans le bucket <strong>sans Firebase Auth</strong>. Fermer avant de déployer casse ses uploads. La fermeture est donc séquencée <strong>après</strong> la mise en production du lot 0 du Studio (upload par URL signée + ingestion serveur), comme le prévoit <code>v2/studio-plan.md</code> §8. ⚠️ <strong>La date de ce déploiement est donc une date de sécurité, pas seulement une date de feature</strong> — ne pas mettre la version actuelle en production sans traiter ce point dans la foulée.</p>
<p><strong>Piège à traiter dans le même geste</strong> : le CORS du bucket de prod n'a <strong>aucun <code>responseHeader</code></strong>. Le jour du déploiement, le <code>PUT</code> signé portant <code>Content-Type</code> et <code>x-goog-content-length-range</code> sera refusé au préflight et <strong>tous les uploads casseront</strong>. À corriger sur le bucket avant la bascule. Le bucket de dev <code>mymuseum-3b97f-dev</code>, lui, est déjà configuré correctement (CORS, cycle de vie <code>incoming/</code>, règles fermées) et sert de modèle.</p>
<p>Une piste applicable <strong>avant</strong> le déploiement, à vérifier : restreindre <code>read</code>/<code>list</code> seul. Les apps visiteur lisent par URL à jeton, qui ne passe pas par les règles ; reste à confirmer qu'aucun code client ne lit par le SDK Firebase.</p>

View File

@ -1,9 +1,12 @@
---
title: SectionForm — formulaires personnalisés
title: SectionForm — formulaires visiteurs
area: backend manager visitapp
horizon: v2
tags: produit
tags: produit, backend, manager-app, visitapp-web, tablet-app
flag: warn | V2
src: todo-features.md — SectionForm
src: v2/section-form-plan.md — analysé le 15/09
---
<p>Nouveau type de section + table de réponses anonymes, constructeur de formulaire et page de résultats agrégés. <strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant — rien n'oblige à le faire passer avec la migration.</p>
<p>Nouveau type de section : le lieu compose un questionnaire (texte, texte long, choix unique, choix multiple, note 1-5), le visiteur y répond <strong>anonymement</strong>, l'admin lit les réponses agrégées dans un onglet de la section, avec export CSV. Enquête de satisfaction, livre d'or, sondage d'exposition.</p>
<p><strong>Reporté en V2 le 07/08</strong> : purement additif, aucun impact sur le schéma existant. <strong>Analysé dans le code le 15/09</strong> — plan complet dans <code>v2/section-form-plan.md</code>, décisions arrêtées, ne pas refaire l'analyse.</p>
<p>Ce qui remonte aujourd'hui du visiteur est uniquement <em>dérivé</em> (<code>VisitEvent</code>, <code>VisitorQuestion</code>) : c'est le premier mécanisme qui permet à un lieu de <strong>poser une question</strong>. La moitié de la plomberie existe déjà — <code>StatsController.TrackEvent</code> pour l'écriture anonyme, <code>SectionQuiz</code>/<code>QuizQuestion</code> pour la structure, <code>VisitorQuestionPurgeService</code> pour la rétention.</p>
<p><strong>Trois fronts visiteur, kiosk compris.</strong> Piège propre à <code>tablet-app</code> : sur borne fixe le visiteur suivant hérite de l'écran du précédent — reset après soumission et sur inactivité. Et un piège backend : <code>GetEmbeddableText</code> doit indexer les libellés de questions mais <strong>jamais les réponses</strong>, sinon le guide IA récite les avis des visiteurs précédents.</p>

View File

@ -1,9 +0,0 @@
---
title: Ressource 360° — images panoramiques
area: backend manager visitapp
horizon: v2
tags: produit
flag: warn | V2
src: todo-features.md — Ressource 360°
---
<p>Une valeur d'enum (<code>Panorama360</code>) et un branchement dans l'affichage des ressources : s'affiche partout où une ressource s'affiche, sans toucher aux sections. <strong>Reporté en V2 le 07/08</strong> — le plus petit des trois, à reprendre en premier.</p>

View File

@ -3,9 +3,12 @@ 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 · lots 0-6 codés, calibrage fait le 15/09, gate conservateur à passer
src: v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0), décisions 22-23
---
<p><strong>15/09 : lots 5 et 6 codés, calibrage §9.4 fait.</strong> Mention IA dans le fichier (XMP IPTC) + gravure en option. Calibrage sur 61 générations (Fourneau en gravure, Fort en aquarelle) : trois défauts corrigés avant tout client — négatif en double négation, titre de fiche recopié en légende, date d'un objet effacée. Coût réel fal : 0,03 $ l'image + 0,015 $ par référence ; <strong>unité arrêtée : 1 crédit = 0,015 $ de coût</strong>, vendu avec marge (≈ 0,05 €). Planches dans <code>outputs/studio-calibrage-2026-09-15/</code>. <strong>Reste</strong> : jugement visuel de Thomas, test du conservateur (gate lot 4), push de la branche.</p>
<p>🛠️ <strong>Lots 0 à 4 codés le 13/09</strong> sur la branche <code>studio</code> (manager-service, manager-app, mymuseum-visitapp) : ingestion serveur, crédits, identité visuelle (menu <strong>Studio</strong>), génération fal.ai derrière <code>IGenerationProvider</code> (fournisseur interchangeable), onglet <strong>Générer</strong> dans le sélecteur de ressource, image d'étape rattachée à une ressource. <strong>Reste avant le gate</strong> : bucket de dev + CORS, clé fal.ai (<code>Studio__Fal__Key</code>), migrations appliquées, calibrage et prix (§9.4), puis le test du conservateur.</p>
<p><strong>Plan consolidé le 13/09.</strong> 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 : <strong>upload par URL signée + ingestion serveur</strong> (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 <code>Grant</code> manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en <strong>ED25519, pas en HMAC</strong>.</p>
<p><strong>Le produit n'est pas l'accès aux modèles, c'est la cohérence.</strong> 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 <code>VisualIdentity</code> par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + <strong>surcharge optionnelle par configuration</strong> — le parcours Halloween a sa propre identité, exactement comme <code>Configuration</code> porte déjà ses <code>PrimaryColor</code>/<code>Languages</code>. Injectée côté serveur dans chaque prompt, <strong>jamais retapée</strong>. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.</p>
<p><strong>Prérequis dur, carte séparée</strong> : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.</p>
<p>⚠️ <strong>Quatre pièges déjà tranchés.</strong> Une <strong>URL Firebase à jeton est publique</strong> — un brouillon dans <code>pictures/</code> serait servable au visiteur : préfixe <code>studio-drafts/</code> + proxy authentifié. Le <strong>quota IA existant ne tient pas</strong> sur des jobs parallèles (<code>CheckQuota</code> avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un <code>CreditLedger</code>. Le <strong>plafond dur n'arrête pas le stagiaire</strong> — c'est un plafond <em>par utilisateur et par jour</em> qu'il faut, pas un plafond organisation. Et <code>GuidedStep.ImageUrl</code> <strong>est une URL, pas un <code>ResourceId</code></strong> : les images générées de l'escape game <em>ne partiraient pas offline</em> (<code>GuidedStep.cs:74</code>) — migration à faire dans le lot MVP.</p>

View File

@ -0,0 +1,23 @@
---
title: Loader animé — presets paramétriques, pas de format de fichier
area: manager
horizon: v2
tags: manager-app, visitapp, tablet, vr
flag: good | Décision arrêtée, à exécuter après la bascule
src: décision 2026-09-15 — analyse des formats de loader animé
---
<p>Aujourd'hui le loader est une image fixe : <code>Configuration.LoaderImageUrl</code> / <code>LoaderImageId</code>, rendus par <code>loading_common.dart</code> dans les deux apps Flutter et par <code>SplashScreen</code> côté web. Le besoin : que le client compose un loader <em>animé</em> depuis le manager, sans fournir de fichier.</p>
<p><strong>Décision : pas de format de fichier animé. On transporte des paramètres.</strong> Un nouveau champ de configuration porte <code>{preset, couleurs[], logoResourceId, vitesse}</code> — quelques centaines d'octets — et chaque front implémente nativement les 5-6 mêmes presets : <code>AnimationController</code> en Flutter, CSS/Canvas en Next.js, animation Unity côté casque. Le preset n°1 existe déjà et tourne : <code>manager-app/lib/Components/loader_animated_pieces.dart</code> (6 pièces vectorielles, onde d'opacité, rotation, flottement) — il suffit d'en sortir les couleurs et les timings, aujourd'hui en dur.</p>
<p>Les couleurs sont pré-remplies depuis <code>VisualIdentityDTO.palette</code>, qui existe déjà. Aucun crédit Studio débité : le rendu est local au navigateur, rien ne passe par un modèle.</p>
<p><strong>Le SVG animé est écarté</strong> : <code>flutter_svg</code> ignore <code>&lt;animate&gt;</code>, SMIL et les keyframes CSS — il rendrait une image figée dans les trois apps Flutter — et Unity n'a aucun rendu SVG. Un seul des quatre fronts l'afficherait.</p>
<p><strong>Lottie est écarté à ce stade</strong>, malgré son écosystème. Format de fait et non norme, gouverné par une société unique (LottieFiles) sur une spec récente ; surtout, <em>aucun runtime moteur de jeu n'est listé sur le site officiel</em>. Les deux options Unity sont fragiles : <code>thorvg.unity</code> (Android arm64 annoncé, mais 26 commits et 14 étoiles, successeur de <code>Lottity</code> archivé en 11/2025) et <code>unity-rlottie</code> (plus fourni mais en <em>experimental</em>, avec un ticket ouvert sur une texture NULL en build Android). Les deux rastérisent en <code>Texture2D</code> à chaque frame côté CPU — inacceptable sur Quest à 72-90 Hz pour un écran de chargement.</p>
<p><strong>Porte de sortie assumée</strong> : le jour où un client veut apporter <em>sa</em> propre animation faite par une agence, on ajoute Lottie sur les 3 fronts 2D seulement, avec le PNG de première frame en repli côté VR. Le champ loader accepte alors soit des paramètres, soit une URL — rien de ce qui est fait ici n'est à refaire.</p>
<p><strong>Coût réel : les presets.</strong> Chacun est du code dans 4 dépôts, à tester sur les 4 fronts. En sortir 5-6, pas 20 : au-delà le client ne choisit plus, il se perd. Stocker les paramètres permet la réédition six mois plus tard.</p>
<p>⚠️ <strong>Pas un bloquant V1</strong> — le <code>loaderImageUrl</code> actuel fait le travail avec un PNG. À ouvrir après la bascule prod.</p>

View File

@ -1,11 +1,13 @@
---
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
flag: critical | Prérequis dur
src: v2/studio-plan.md — lot 0 · v2/media-storage-plan.md
tags: manager-service, manager-app, Studio IA, sécurité
flag: warn | Code fait le 13/09, infra à faire
src: v2/studio-plan.md — §8 lot 0, décisions 14, 18, 20
---
<p><code>IResourceBlobService</code> n'expose que <code>DeleteAsync</code> : <strong>tout l'upload est fait par le navigateur en direct</strong> (<code>resources_screen.dart:247-256</code>). Or le Studio produit ses images côté serveur, et le TTS pré-généré aussi. Il faut <code>UploadAsync</code>, <code>CopyAsync</code>, <code>ProbeSizeAsync</code> — le credential est <em>déjà</em> chargé pour FCM, seul <code>Firebase:StorageBucket</code> est vide en config (carte dédiée en colonne Bascule prod).</p>
<p>⚠️ <strong><code>ImageHelper</code> est du code mort</strong> : <code>ResourceController.Upload</code> (base64 + resize + watermark) s'appuie sur <code>System.Drawing.Common</code>, qui ne tourne pas sur l'image <code>aspnet:8.0</code> Linux. À réécrire en <strong>ImageSharp</strong> — c'est aussi ce qui portera la compression serveur 2560&nbsp;px / q82 et l'option de gravure du watermark.</p>
<p><strong>Un lot, deux modules servis.</strong> Le TTS pré-généré attend exactement la même chose (<code>tts-pregenerated-plan.md</code> : « upload serveur via Firebase Admin SDK »). Le faire une fois, proprement, débloque les deux. À caser avec l'ajout de <strong><code>LB</code> (luxembourgeois)</strong> aux langues supportées — absent des 4 repos, zéro occurrence, et bloquant pour le projet luxembourgeois en cours indépendamment du Studio.</p>
<p>🛠️ <strong>Code livré le 13/09</strong> (branche <code>studio</code>) : URL signée, <code>ResourceIngestionService</code>, ImageSharp, upload avec progression dans manager-app, SDK Firebase retiré. <strong>Reste l'infra</strong> ci-dessous, puis la checklist du §8.</p>
<p><code>IResourceBlobService</code> n'expose que <code>DeleteAsync</code> : <strong>tout l'upload est fait par le navigateur en direct</strong>, à deux endroits de <code>resources_screen.dart</code>. Or le Studio et le TTS pré-généré produiront côté serveur.</p>
<p>⚠️ <strong>Trou de sécurité actuel, indépendant du Studio.</strong> manager-app n'a pas de Firebase Auth (<code>firebase_storage</code> sans <code>firebase_auth</code>) et écrit pourtant dans le bucket : les règles Storage sont très probablement <strong>ouvertes en écriture</strong>. Aucun <code>storage.rules</code> dans le repo — à relever dans la console.</p>
<p><strong>Tranché le 13/09 : URL signée + ingestion.</strong> L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans <code>incoming/</code>, puis <code>POST /ingest</code> : un <code>ResourceIngestionService</code> unique — le même pour le Studio et le TTS — mesure la <strong>taille réelle</strong>, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous <code>pictures/</code>. 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.</p>
<p>Infra : CORS du bucket pour le <code>PUT</code>, règle de cycle de vie à 1 jour sur <code>incoming/</code>, et fermeture des règles <strong>après</strong> le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route <code>upload</code> morte et d'<code>ImageHelper</code>, <code>Firebase:StorageBucket</code> renseigné, et <strong><code>LB</code> (luxembourgeois)</strong>.</p>

View File

@ -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)
---
<p><strong>Constat du 13/09.</strong> Le seul code de watermark vivait dans <code>ResourceController.Upload</code> (base64, <code>System.Drawing</code>) : il écrivait le texte <strong>« fortsaintheribert.be » en dur</strong>, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, <strong>aucune image n'est filigranée</strong>, même sur une instance où <code>Instance.IsImageWatermark</code> est activé.</p>
<p>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.</p>
<p><strong>À trancher avant de le refaire</strong>, dans le <code>ResourceIngestionService</code> du lot 0 (le seul endroit qui voit passer toutes les images) :</p>
<ul>
<li>le Fort le veut-il encore ?</li>
<li>texte ou logo, et <strong>par instance</strong> au lieu d'un nom de domaine en dur ;</li>
<li>une police embarquée dans le repo — Arial n'existe pas dans le conteneur <code>aspnet:8.0</code> Linux — via <code>SixLabors.ImageSharp.Drawing</code> ;</li>
<li>jamais sur une <code>Image360</code>, comme la compression.</li>
</ul>
<p>Distinct de la gravure « générée par IA » du Studio (<code>Instance.IsAiWatermarkBurned</code>, décision 6), qui réutilisera le même chemin.</p>

View File

@ -3,9 +3,11 @@ title: Personnages — un objet, quatre facettes
area: visitapp backend
horizon: v2
tags: 3 repos, Studio IA
flag: warn | V2 · remplace le talking head
flag: good | V2 · lots 8 et 8bis codés le 15/09, à tester
src: v2/studio-plan.md §3.8 — lots 7 et 8
---
<p><strong>Lot 8 et 8bis codés le 15/09, à tester</strong> (plan : <code>test-plan-studio-narration.md</code>). Narrateurs assignables (étape, point, article ; défaut parcours, carte, visite ; guide par visite) avec la chaîne de § 3.8 ; génération audio Gemini TTS → MP3, 1 crédit par minute, état « à régénérer » sur changement de texte ou de voix ; onglet Narrateurs du parcours, blocs sur l'article et le point, carte Narration et casting dans l'écran Configuration ; portrait du narrateur à côté du lecteur et écran « les personnes que vous allez rencontrer » sur les trois apps visiteur. <strong>Reste</strong> : les extraits d'écoute des voix (clé Gemini). Le lecteur audio d'un point de carte côté visiteur a été ajouté au lot (8e).</p>
<p>🛠️ <strong>Lot 7 en cours (15/09).</strong> Entité <code>Persona</code>, écran Personnages, <code>TtsVoice</code> en base, migration des colonnes <code>Instance.Guide*</code> faits. Figures des 12 archétypes générées et embarquées. <strong>Reste</strong> : l'écoute des voix (échantillons audio à produire, pas de clé Gemini en local), puis le lot 8 (narration attribuée).</p>
<p><strong>Trois plans décrivaient le même objet sans se croiser</strong> (relevé le 01-02/09) : le canon visuel (<code>studio-plan.md</code>), les 3 frames de lipsync (<code>talking-head-plan.md</code>) et <code>PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }</code> (<code>tts-pregenerated-plan.md</code>). Une entité <code>Persona</code> les remplace : identité, visage (canon en <em>jeu de vues</em>), voix + intonation, parole.</p>
<p><strong>Le talking head est abandonné, sa dépendance était déjà cassée.</strong> <code>talking-head-plan.md:11</code> anime ses frames « selon les timestamps retournés par <strong>Google Cloud TTS</strong> » (<code>enable_time_pointing</code>) — or le code livré tourne sur <strong>Gemini TTS</strong> (<code>GeminiTtsEngine</code>, <code>gemini-2.5-flash-preview-tts</code>) : aucune source de timestamps. Remplacé par le <strong>portrait canon statique</strong> à côté du lecteur audio, puis une boucle vidéo 6-8 s si du mouvement est voulu. En-tête d'abandon posé sur le fichier.</p>
<p>⚠️ <strong>« Le visiteur choisit entre Viva et Marco » était un choix de voix déguisé en choix de guide.</strong> Le besoin réel est celui du <strong>Bastogne War Museum</strong> : 3-4 personnages qui narrent chacun <em>leurs</em> stations, avec leur voix et leur intonation. Le conservateur assigne (<code>NarratorPersonaId</code> sur <code>GuidedStep</code>/<code>GeoPoint</code>/<code>SectionArticle</code>), le visiteur ne choisit rien. <strong>Bonne nouvelle : ça divise le stockage TTS par deux au lieu de le multiplier</strong> — chaque contenu n'existe plus qu'en une voix.</p>

View File

@ -0,0 +1,27 @@
---
title: Studio dans l'app VR — mention IA, narration, casting (lot 8f)
area: vr backend
horizon: v2
tags: VR, Studio IA
flag: warn | V2 · après le test du lot 8 sur les trois apps, avec le casque
src: v2/studio-plan.md — lot 8f
---
<p><strong>Le lot 8 (narrateurs, audio généré, portrait, casting) a été livré sur les trois apps visiteur — web, mobile, tablette — mais pas sur l'app Quest</strong>, qui lit pourtant le contenu par l'export de configuration (<code>ConfigurationExport.cs</code>). Relevé le 2026-09-15.</p>
<p><strong>Ce que fait la VR aujourd'hui</strong> : Map et Parcours rendus en liste de points, sans lire <code>audioIds</code> ; hotspots de maquette 3D dont l'audio vient des <em>contenus</em> du point, pas de l'audio par langue ; ni portrait du narrateur, ni écran « les personnes que vous allez rencontrer ».</p>
<p><strong>À faire</strong> :</p>
<ol>
<li>Export Unity : <code>audioIds</code> des points et des étapes, <code>narratorName</code> / <code>narratorPortraitUrl</code> sur la ressource, <code>casting</code> de la configuration.</li>
<li>Lecture de l'audio narré d'un point et d'une étape, portrait à côté (audio spatialisé sur un hotspot).</li>
<li>Serveur : rendre narrables les points d'une maquette 3D — la section maquette comme conteneur dans la chaîne de <code>NarratorResolver</code> (aujourd'hui, un point sans <code>SectionMapId</code> est refusé par <code>NarrationService</code>).</li>
<li>Écran d'entrée du casting au lancement de la visite.</li>
</ol>
<p>🔎 <strong>Repasse du 2026-09-15 sur les lots Studio 0 à 8</strong> (code de <code>vr-app</code> relu) — ce qui touche le visiteur et manque au casque :</p>
<ul>
<li><strong>Lot 6, mention « image générée par IA » — manquante.</strong> <code>ConfigurationExport.Resource</code> ne lit pas <code>origin</code> : les images d'un Slider, la photo d'un point et les médias d'un hotspot s'affichent sans mention. <code>ProvenanceManifest.AiGenerated</code> existe dans le manifeste mais rien ne le remplit depuis l'export. À ajouter avec le reste — c'est l'obligation d'information de l'AI Act, pas un confort.</li>
<li><strong>Lot 8, audio narré — manquant</strong> (points 1 et 2 ci-dessus). Le commentaire d'un hotspot vient de ses <em>contenus</em> ; la narration écrit dans <code>audioIds</code>, que le casque ne lit pas.</li>
<li><strong>Lot 8bis, casting — manquant</strong> (point 4).</li>
<li><strong>Lot 7, personnages — couture prévue, pas faite.</strong> <code>ScenePersona.PersonaId</code> est « null en V1, la couture vers l'entité <code>Persona</code> » : les personnages GLB d'une scène ne sont pas reliés aux fiches Personnages (nom, portrait, voix). Relève plutôt du lot 11 (3D).</li>
<li><strong>Sans objet</strong> : lots 0 à 5 (serveur et manager), crédits et calibrage. Le correctif « audio rangé par identifiant » de 8d ne s'applique pas : le casque résout déjà ses médias par identifiant via <code>FindResource</code>.</li>
<li><strong>Hors Studio, vu en passant</strong> : un <strong>Parcours</strong> est rendu « comme une Map » à partir de <code>section.Points</code>, or ses étapes vivent dans <code>guidedPaths</code> — un Parcours s'ouvre probablement vide sur le casque. À vérifier casque en main.</li>
</ul>
<p><strong>Pas avant</strong> : que le plan de test <code>test-plan-studio-narration.md</code> soit passé sur les trois autres apps, et avec le casque en main — rien de ceci ne se vérifie sans l'éditeur Unity. Le dépôt <code>vr-app</code> n'a toujours ni remote ni commit.</p>

View File

@ -1,11 +0,0 @@
---
title: Back-office XR — <code>Device.AppType</code> + onglet flotte de casques
area: backend manager
horizon: v2
tags: XR
flag: warn | V2 · nouveau 31/08
src: v2/vr-quest-unity-plan.md §1, §2 (lots V-1 à V-3)
---
<p>⚠️ <strong>« Zéro ligne de code » était faux</strong> — relevé dans le code le 31/08. Le canal VR est déjà dans le modèle : <code>AppType.VR</code> (valeur 3) côté C# <em>et</em> dans <code>manager_api_new</code>, <code>Instance.IsVR</code> en base depuis la migration de juillet 2025, sous-menu « VR » branché dans <code>main_screen.dart:65</code>, <code>AppConfigurationLink.DeviceId</code> (le mécanisme « une config par appareil » du kiosk), filtre stats et i18n <code>statsChannelVR</code> prêts.</p>
<p><strong>Deux trous seulement.</strong> <code>main_screen.dart:704</code> est littéralement un <code>Text("TODO vr")</code> ; et <code>DeviceController.Create</code> est <strong>hardcodé sur <code>AppType.Tablet</code></strong> (<code>DeviceController.cs:155</code>), donc un casque enregistré aujourd'hui atterrirait dans l'onglet Kiosk. Il faut un <code>Device.AppType</code> (défaut <code>Tablet</code>, l'existant ne bouge pas) et <code>ApiKeyAppType.VrApp</code> <em>en fin d'enum</em> — il est persisté en int.</p>
<p>L'écran est un <strong>clone de <code>Kiosk_devices/</code></strong> (4 fichiers, 769 l.) : grille de casques, pincode d'appairage, assignation d'une configuration par appareil, plus batterie / <code>AppVersion</code> / <code>LastSeen</code> — colonnes déjà en base, jamais affichées pour le kiosk. <strong>Estimé 8-12 j au total</strong> (backend 2-3 j, écran 4-5 j, contrat de contenu 1-2 j), et ce lot <strong>se tient debout tout seul</strong> : il rend le canal administrable et démontrable avant tout engagement sur Unity.</p>

View File

@ -3,11 +3,30 @@ title: Meta Quest — app Unity + Meta XR SDK
area: doc
horizon: v2
tags: XR
flag: warn | V2 · subventionné · go/no-go
src: v2/vr-quest-unity-plan.md §2 (lots V-4, V-5), §4, §5 · roadmap.md — section XR
flag: warn | V2 · POC écrit en entier, rien n'a jamais tourné
src: v2/vr-quest-unity-plan.md §2 (lots XR-4, XR-5), §4, §5 · roadmap.md — section XR
---
<p>Stack tranchée le 31/08 : <strong>Unity 6 LTS + OpenXR + Meta XR feature group</strong>, pas React Native. Le contenu se récupère par <code>GET /api/configuration/{id}/export</code> — un seul appel qui renvoie configuration + sections + ressources, donc pas de DTO C# à réécrire endpoint par endpoint. <strong>POC 3-4 semaines, couverture complète 6-10 semaines</strong>, + 1-2 sem. de supervision de flotte (le publish MQTT serveur existe déjà, <code>DeviceController.cs:303</code>).</p>
<p>⚠️ <strong>Le risque est produit, pas technique.</strong> Le CMS est 2D : un Article sur un panneau flottant est <em>moins</em> bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent — et la <strong>ressource 360° est un prérequis dur</strong> (carte dédiée dans cette colonne). <strong>Go/no-go conditionné à un client pilote disposant déjà de vidéos 360°</strong> ; sans lui, le POC démontrera une régression.</p>
<p><strong>Unity n'est pas une nécessité technique</strong> (tranché le 31/08) : le mode kiosk vient de la <em>gestion d'appareil</em>, pas du moteur — un MDM verrouille n'importe quelle app installée, y compris un navigateur épinglé. Unity gagne sur l'<strong>exploitation sans surveillance</strong> : cache offline multi-Go, décodage 8K, et surtout un build <em>figé</em> là où le navigateur du casque s'auto-met à jour et peut casser la borne. Contre-argument sérieux à garder : <code>visitapp-web</code> rend déjà les 13 types en React, donc une couche WebXR attaquerait le risque « quatrième front à maintenir ». <strong>Retenu : WebXR pour la maquette, Unity pour la borne</strong> — à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».</p>
<p>⚠️ <strong>MDM rétrogradé le 31/08</strong> : inutile à 1-3 casques (sideload + mode appareil suffisent), ne redevient un sujet qu'au-delà d'une dizaine.</p>
<p>⚠️ <strong>Les Ray-Ban ne sont plus dans cette carte : elles sont passées en V1 le 12/08</strong>, et leur volet stats est livré depuis le 13/08 (voir « Fait récemment »).</p>
<p><strong>Stack tranchée le 31/08</strong> : Unity 6 LTS + OpenXR + Meta XR feature group. Le contenu se récupère par <code>GET /api/configuration/{id}/export</code> — un seul appel qui renvoie configuration, sections et ressources, donc aucun DTO C# à réécrire endpoint par endpoint. <strong>Unity n'est pas une nécessité technique</strong> : 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 <em>figé</em> là où le navigateur du casque s'auto-mettrait à jour et pourrait casser la borne un mardi matin. <strong>Retenu : WebXR pour la maquette, Unity pour la borne</strong>, à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».</p>
<p><strong>Le POC est écrit en entier — E0 à E10, entre le 11 et le 12/09.</strong> Unity 6000.0.83f1, projet URP dans <code>vr-app/</code>, 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.</p>
<table>
<tr><th>Item</th><th>Ce qui est écrit</th></tr>
<tr><td><strong>E2-E3</strong></td><td>Appairage par code PIN (<code>PairingService</code>) et lecture de l'export. ⚠️ Un <strong>404 sur <code>POST /api/device</code></strong> ne veut pas dire « introuvable » : il n'y a pas d'<code>ApplicationInstance</code> VR sur l'instance.</td></tr>
<tr><td><strong>E4</strong></td><td><strong>Cache d'abord</strong> : 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.</td></tr>
<tr><td><strong>E5</strong></td><td>Menu flottant en arc à 2,2 m, qui <strong>ne suit pas la tête</strong>. <strong>Trois moyens de viser</strong> : manette (gâchette), main (pincement), tête (1,2 s, contre le « Midas touch »). ⚠️ <strong>« À la tête », pas « aux yeux »</strong> — le Quest 2 n'a pas d'eye tracking. ⚠️ La dépendance <code>E5 → E1</code> du plan <strong>était fausse</strong> : <code>OVRInput</code> et <code>OVRHand</code> viennent du Core SDK, donc <strong>E1 sort du chemin critique</strong>.</td></tr>
<tr><td><strong>E6</strong></td><td>Image et vidéo 360° en skybox. Le <strong>retour au menu ne reste pas planté dans le décor</strong> : 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 <strong>invisibles dans l'éditeur</strong> : <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em> (sinon ciel magenta sur le casque seul), et une équirectangulaire décodée pèse <strong>134 Mo</strong> — compressée en ASTC et libérée à la sortie, sinon l'app meurt après quelques 360.</td></tr>
<tr><td><strong>E8</strong></td><td>Slider, Map, Parcours et Event, dans une même vue paginée. ⚠️ <strong>Deux écarts assumés</strong> : la Map est une <strong>liste de POI</strong>, pas la maquette 3D promise (elle demande <code>SectionModel3D</code>) ; le Parcours est rendu comme une Map, pour la même raison.</td></tr>
<tr><td><strong>E9</strong></td><td>Télémétrie « tire et oublie ». ⚠️ Route <code>/api/stats/event</code>, et <code>appType</code> y part en <strong>chaîne parsée par nom</strong> : une faute de frappe retombe sur Mobile <em>sans erreur</em>.</td></tr>
<tr><td><strong>E10</strong></td><td>Fin de visite à la repose du casque ou après 90 s, menu <strong>recentré devant le visiteur suivant</strong>, nouvelle session de stats. ⚠️ Le verrouillage de l'app sur le casque n'est pas du code : c'est le mode appareil de Meta.</td></tr>
</table>
<p>⚠️ <strong>Rien de E2 à E10 n'a jamais tourné</strong> — ni contre un vrai serveur, ni sur le casque. Le code compile, c'est tout ce qu'on sait. <strong>Plan de test §25</strong>, 9 blocs, écrit pour ça ; son §25.7 (déclarer une 360 dans le manager) se joue <strong>sans casque</strong>.</p>
<p>⚠️ <strong>Le risque est produit, pas technique, et il est intact.</strong> Le CMS est 2D : un Article sur un panneau flottant est <em>moins</em> bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent. <strong>Go/no-go conditionné à un client pilote disposant déjà de vidéos 360°</strong> — rien de ce qui a été codé ne répond à cette question-là.</p>
<p>🟡 <strong>XR-5, la supervision de flotte : l'essentiel est écrit</strong> (12/09). Un <strong>battement HTTP</strong> toutes les 3 min remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR, qui affichaient « — » faute d'alimentation — <code>Update</code> n'écrivait ni <code>AppVersion</code> ni <code>LastSeen</code>. Pas de MQTT : à 1-3 casques il coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour <strong>pousser</strong> vers le casque, pas pour remonter. <strong>Reste</strong> : le client MQTT le jour où l'on voudra recharger une configuration à distance, et une alerte quand un casque ne donne plus signe.</p>
<p>🟡 <strong>E7, la maquette 3D, est écrit aussi</strong> (12/09). <code>SectionModel3D</code> est <strong>un vrai type de section</strong> — une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres — mais il réutilise le <code>GeoPoint</code>, qui portait déjà deux rattachements : les points gardent titre, description, audio et multilingue. ✅ <strong>L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial du lot, n'était pas à écrire</strong> : <code>vr-app/viewer</code> sait déjà le faire et <strong>son protocole <code>postMessage</code> existait déjà</strong>, 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.</p>
<p><strong>Reste au lot</strong> : <code>E11</code> (distribution : Horizon Store ou canal privé, à revérifier avant toute offre). ⚠️ <strong>MDM rétrogradé le 31/08</strong> : inutile à 1-3 casques, sideload + mode appareil suffisent.</p>

View File

@ -0,0 +1,7 @@
---
title: Back-office XR — le canal VR est administrable
---
<p><strong>Clos le 12/09.</strong> Le chantier tenait en trois lots, et les trois sont retombés : <strong>XR-1</strong> le 11/09 (<code>Device.AppType</code>, migration <code>20260911135527_AddAppTypeToDevice</code>, <code>DeviceController.Create</code> qui résout l'<code>ApplicationInstance</code> sur <code>appType</code>, <code>ApiKeyAppType.VrApp</code> en fin d'enum, 224 tests au vert), <strong>XR-3</strong> qui était déjà fait sans que le plan le sache (export ouvert aux apps depuis <code>9cc45c5</code>), et <strong>XR-2</strong> le 12/09.</p>
<p>L'écran XR est une coquille à <strong>deux sous-onglets</strong> : « Configuration », qui réutilise <code>AppConfigurationLinkScreen</code> tel quel — il est déjà générique sur l'<code>appType</code>, zéro code neuf —, et « Casques », la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL.</p>
<p>⚠️ <strong>Deux affirmations du plan étaient fausses, relevées dans le code.</strong> La grille kiosk ne liste pas des <code>Device</code> mais des <code>AppConfigurationLink</code> : le filtre par canal était déjà implicite, et le paramètre <code>appType</code> de <code>/api/device</code> ajouté par XR-1 ne sert pas à cet écran. Et batterie / version / dernier vu ne sont pas dans <code>DeviceDTO</code>, seulement dans <code>DeviceDetailDTO</code> — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités.</p>
<p>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 <strong>XR-4, l'app Unity</strong> — carte séparée.</p>

View File

@ -0,0 +1,10 @@
---
title: Ressource 360° — et la lecture immersive qu'elle débloque
---
<p><strong>Clos le 12/09.</strong> Trois valeurs ajoutées <strong>en fin</strong> de <code>ResourceType</code><code>Image360</code> (11), <code>Video360</code> (12), <code>Model3D</code> (13) — <strong>sans migration</strong> : la colonne est déjà <code>integer</code>, 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.</p>
<p>La carte disait « une valeur d'enum et un branchement dans l'affichage ». <strong>Il manquait les deux morceaux qui comptent.</strong></p>
<p><strong>Un : comment on déclare une 360.</strong> Aucune extension ne le dit — un panorama est un <code>.jpg</code> comme un autre. La pastille de type du sélecteur de médias devient donc <strong>cliquable</strong> : elle bascule Image ↔ Image 360°, Vidéo ↔ Vidéo 360°, et se colore quand c'est immersif. Le <code>.glb</code>, lui, se déduit seul : un GLB n'est jamais autre chose qu'un modèle 3D.</p>
<p><strong>Deux, et c'est le vrai piège : la compression.</strong> <code>ImageCompressor</code> 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 <strong>deux</strong> chemins d'upload, qui avaient déjà divergé deux fois par le passé.</p>
<p><strong>Bonne surprise</strong> : le prérequis « le backend ne sait pas écrire dans le bucket », qui bloque le Studio et le TTS, <strong>ne s'applique pas ici</strong><code>manager-app</code> pousse directement dans Firebase Storage puis enregistre l'URL. L'ancienne route serveur <code>/upload</code> plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.</p>
<p>➡️ <strong>Ce que ça débloque</strong> : <code>E6</code> du lot XR-4, la lecture 360° dans le casque, écrite dans la foulée (<code>SkyboxView</code>, image et vidéo dans le même shader panoramique). C'était le dernier prérequis du POC VR. ⚠️ <code>Skybox/Panoramic</code> doit être dans <em>Always Included Shaders</em>, sinon le ciel sort magenta sur le casque et correct dans l'éditeur. Plan de test §25.7 — sa première moitié se joue <strong>sans casque</strong>.</p>
<p><strong>Non fait, et assumé</strong> : l'exploitation du <code>Model3D</code>. 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 <code>E7</code>, et le §9 du plan VR le décrit.</p>

View File

@ -0,0 +1,14 @@
---
title: L'appairage d'une nouvelle tablette répondait 403 depuis mars
---
<p><strong>Trouvé et corrigé le 12/09</strong>, en écrivant l'appairage du casque : il butait sur le même mur.</p>
<p><code>DeviceController</code> porte <code>[Authorize(InstanceAdmin)]</code> <strong>sur la classe</strong>, et <code>Create</code> n'avait aucune exception. Or une clé d'API ne porte que <code>AppRead</code> et <code>Viewer</code> (<code>ApiKeyAuthenticationHandler</code>), et <code>tablet-app</code> <strong>ne s'authentifie jamais autrement</strong> — aucun appel d'authentification dans tout le repo. <code>POST /api/device</code> répondait donc <strong>403 à toute tablette</strong>.</p>
<p><strong>Depuis quand</strong> : commit <code>a452f4a</code> du <strong>13/03/2026</strong>, dont le message dit lui-même « need to be tested ». <strong>Pourquoi personne ne l'a vu</strong> : une tablette déjà appairée ne rappelle jamais <code>Create</code>. Le parc existant continuait de fonctionner ; seul un <em>nouvel</em> appareil échouait — c'est-à-dire exactement ce qu'on ne fait pas tous les jours.</p>
<p><strong>Correctif</strong> : <code>Create</code> passe en <code>[AllowAnonymous]</code> + <code>[RequireAppKey]</code>, 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 <strong>son</strong> instance.</p>
🔴 <strong>Et ce n'était pas que l'appairage.</strong> En auditant les clients le 12/09, deux autres maillons du même mur :</p>
<ul>
<li><code>GET /api/Device/{id}/detail</code> était fermé aux clés lui aussi — or c'est le <strong>premier appel d'une tablette qui démarre</strong> (<code>tablet-app/lib/main.dart:46</code>) : celui qui lui dit quelle configuration afficher. Une tablette <em>déjà appairée</em> ne retrouvait donc plus son contenu au redémarrage. Ouvert de la même façon.</li>
<li><code>tablet-app/lib/main.dart:38</code> reconstruisait son client <strong>sans clé</strong><code>Client(host)</code> au lieu de <code>Client(host, apiKey: …)</code> — alors que la clé <em>est</em> persistée en base locale (<code>TabletAppContext.toMap</code>). Une ligne, et tous les appels d'après partaient anonymes.</li>
</ul>
<p>Les deux se tenaient : la route refusait les clés, et l'app n'en envoyait pas. Corrigés ensemble.</p>
<p>⚠️ <strong>À vérifier sur le terrain</strong> : appairer une <em>vraie</em> nouvelle tablette, <strong>et redémarrer une tablette déjà appairée</strong>. Le correctif est couvert par les tests, mais les tests appellent le contrôleur directement — <strong>aucun filtre d'autorisation ne s'y exécute</strong>, donc ils ne prouvent rien sur l'accès lui-même.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

View File

@ -115,13 +115,13 @@ App Meta Quest qui consomme le CMS MyInfoMate existant. Le client configure son
- **Modélisation 3D custom** (visite virtuelle d'un château, etc.) : **sur devis, cas par cas** — ce n'est pas inclus par défaut
#### TODO
> Découpé en lots V-1 à V-5 dans [v2/vr-quest-unity-plan.md](v2/vr-quest-unity-plan.md) §2. Les 3 premiers lots (back-office XR, ~8-12 j) se tiennent debout tout seuls et se font avant tout engagement sur l'app.
> Découpé en lots XR-1 à XR-5 dans [v2/vr-quest-unity-plan.md](v2/vr-quest-unity-plan.md) §2. Les 3 premiers lots (back-office XR, ~8-12 j) se tiennent debout tout seuls et se font avant tout engagement sur l'app.
- [ ] `Device.AppType` — le backend ne sait aujourd'hui enregistrer que des tablettes (lot V-1)
- [ ] Onglet XR dans manager-app : flotte de casques + une configuration par appareil (lot V-2)
- [ ] Figer le contrat de contenu (`/api/configuration/{id}/export`) pour un client non-Flutter (lot V-3)
- [ ] `Device.AppType` — le backend ne sait aujourd'hui enregistrer que des tablettes (lot XR-1)
- [ ] Onglet XR dans manager-app : flotte de casques + une configuration par appareil (lot XR-2)
- [ ] Figer le contrat de contenu (`/api/configuration/{id}/export`) pour un client non-Flutter (lot XR-3)
- [ ] Ressource 360° (déjà en V2 dans todo-features) — **prérequis dur**, sans elle il n'y a rien d'immersif à afficher
- [ ] POC Unity + Meta XR SDK : menu immersif + vidéo 360 + une section riche (lot V-4)
- [ ] POC Unity + Meta XR SDK : menu immersif + vidéo 360 + une section riche (lot XR-4)
- [ ] Client pilote avec contenu vidéo 360° existant — **condition du go/no-go**, à valider avant le POC
---

View File

@ -19,8 +19,8 @@ Plans détaillés, pas encore démarrés en dev (sauf mention contraire) :
| TTS pré-généré (audioguide multilingue) | [v2/tts-pregenerated-plan.md](../v2/tts-pregenerated-plan.md) | Priorité 3. ⚠️ **Trois corrections posées le 2026-09-02** (en-tête du fichier) : le moteur est **Gemini TTS**, pas Cloud TTS — le document disait les deux, le code tranche ; « le visiteur choisit entre Viva et Marco » est remplacé par **un guide adressable + N narrateurs** ; et le stockage s'en trouve **divisé par deux**, pas multiplié. Source de vérité sur les personnages : [v2/studio-plan.md §3.8](../v2/studio-plan.md) |
| ~~Talking head (avatar animé)~~ | [v2/talking-head-plan.md](../v2/talking-head-plan.md) | ⛔ **ABANDONNÉ le 2026-09-02.** Son lipsync repose sur `enable_time_pointing` de **Google Cloud TTS**, or le code livré tourne sur **Gemini TTS** (`GeminiTtsEngine`, `gemini-2.5-flash-preview-tts`) : aucune source de timestamps. Le plan était sans fondation, pas « à faire ». Remplacé par le portrait canon statique d'un `Persona`, puis une boucle vidéo 6-8 s. En-tête d'abandon posé sur le fichier |
| **Médiathèque — refonte de l'onglet Ressources** | ⭐ **[v1-mediatheque-plan.md](../v1-mediatheque-plan.md)** — spec autonome et exécutable, écrite le 2026-09-02 | ⭐ **V1 — prêt à implémenter, sans rien lire du plan Studio.** Rien à voir avec l'IA : renommage en Médiathèque, rail de facettes cumulables (type/usage/origine/configuration), filtre **« jamais utilisée »**, tri, groupement, sélection multiple, et le détail en **panneau latéral** avec la liste « Utilisée dans » — presque gratuite, `GetReferencedResourceIds` existe déjà sur les 13 sous-types. 🐛 Corrige deux bugs : le téléchargement forcé en `.json` quel que soit le type (`show_resource_popup.dart:91`) et `Resource.FileName` absent de `ToDTO()`. **Aucune clé API, aucun coût variable — meilleur ratio valeur/risque du chantier** |
| **Module Studio — génération IA d'images/vidéos/3D** | [v2/studio-plan.md](../v2/studio-plan.md) | 📦 **V2 — conception arrêtée le 2026-09-02, rien de codé.** Le produit n'est pas l'accès aux modèles, c'est la **cohérence visuelle à l'échelle d'un projet** : une `VisualIdentity` par instance + surcharge par configuration, injectée côté serveur dans chaque prompt. La génération se greffe sur le **sélecteur de ressource existant** (`showSelectResourceModal` embarque déjà `ResourcesScreen`) — un seul branchement couvre les 13 types de section, les POI et les étapes. MVP = escape game, images seulement. **Prérequis dur : le backend ne sait pas écrire dans le bucket** (`IResourceBlobService` n'a que `DeleteAsync`) — lot 0 partagé avec le TTS pré-généré. ⚠️ Trois pièges tranchés : URL Firebase à jeton **publique** (préfixe `studio-drafts/` + proxy authentifié), `CheckQuota` avant/après **ne tient pas** sur des jobs Hangfire parallèles (réservation en deux temps sur `CreditLedger`), et `GuidedStep.ImageUrl` est une URL — les images de l'escape game **ne partiraient pas offline**. 🚫 Pas de watermark gravé : provenance + badge UI |
| **Personnages — un guide adressable + N narrateurs** | [v2/studio-plan.md §3.8](../v2/studio-plan.md) | 📦 **V2 — arrêté le 2026-09-02.** **Trois plans décrivaient le même objet sans se croiser** : le canon visuel (studio-plan), les frames de lipsync (talking-head), et `PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }` (tts-pregenerated). Une entité `Persona` les remplace, et les 4 colonnes `Instance.Guide*` deviennent une ligne. ⚠️ « Le visiteur choisit entre Viva et Marco » était **un choix de voix déguisé en choix de guide** : le besoin réel (modèle Bastogne War Museum) est N narrateurs assignés par le conservateur via `NarratorPersonaId` — ce qui **divise le stockage TTS par deux** au lieu de le multiplier. Le **wakeword devient une exigence du canal mains-libres**, pas une propriété du personnage : ça corrige le bug latent où le visiteur dirait « Marco » à quelqu'un qui se présente comme « Léon ». À ajouter : `Persona.VoicePrompt` (l'intonation, aujourd'hui la **constante de build** `kGeminiTtsPrompt`) et une table `TtsVoice` — deux constantes ne suffisent pas à quatre narrateurs |
| **Module Studio — génération IA d'images/vidéos/3D** | [v2/studio-plan.md](../v2/studio-plan.md) | 🛠️ **V2 — lots 0 à 6 codés, calibrage fait le 2026-09-15** (voir studio-plan §9.4 : négatif, contexte, coûts réels, unité de crédit). Conception du 2026-09-02 : Le produit n'est pas l'accès aux modèles, c'est la **cohérence visuelle à l'échelle d'un projet** : une `VisualIdentity` par instance + surcharge par configuration, injectée côté serveur dans chaque prompt. La génération se greffe sur le **sélecteur de ressource existant** (`showSelectResourceModal` embarque déjà `ResourcesScreen`) — un seul branchement couvre les 13 types de section, les POI et les étapes. MVP = escape game, images seulement. ✅ Prérequis bucket levé (lot 0 : URL signée + ingestion serveur). ⚠️ Trois pièges tranchés : URL Firebase à jeton **publique** (préfixe `studio-drafts/` + proxy authentifié), `CheckQuota` avant/après **ne tient pas** sur des jobs Hangfire parallèles (réservation en deux temps sur `CreditLedger`), et `GuidedStep.ImageUrl` est une URL — les images de l'escape game **ne partiraient pas offline**. 🚫 Pas de watermark gravé par défaut : provenance + badge UI + XMP IPTC dans le fichier, gravure en option par instance (lot 6) |
| **Personnages — un guide adressable + N narrateurs** | [v2/studio-plan.md §3.8](../v2/studio-plan.md) | 🛠️ **V2 — lot 7 en cours** (entité `Persona`, écran Personnages, `TtsVoice` en base, figures d'archétypes). Arrêté le 2026-09-02 : **Trois plans décrivaient le même objet sans se croiser** : le canon visuel (studio-plan), les frames de lipsync (talking-head), et `PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName }` (tts-pregenerated). Une entité `Persona` les remplace, et les 4 colonnes `Instance.Guide*` deviennent une ligne. ⚠️ « Le visiteur choisit entre Viva et Marco » était **un choix de voix déguisé en choix de guide** : le besoin réel (modèle Bastogne War Museum) est N narrateurs assignés par le conservateur via `NarratorPersonaId` — ce qui **divise le stockage TTS par deux** au lieu de le multiplier. Le **wakeword devient une exigence du canal mains-libres**, pas une propriété du personnage : ça corrige le bug latent où le visiteur dirait « Marco » à quelqu'un qui se présente comme « Léon ». À ajouter : `Persona.VoicePrompt` (l'intonation, aujourd'hui la **constante de build** `kGeminiTtsPrompt`) et une table `TtsVoice` — deux constantes ne suffisent pas à quatre narrateurs |
| AI Persona par venue (guide IA personnalisable) | [v2/myinfomate-ai-persona-analysis.md](../v2/myinfomate-ai-persona-analysis.md) | Architecture de référence pour les chantiers IA ci-dessus |
| Lunettes Ray-Ban Meta | [roadmap.md](../roadmap.md) (section XR), [rayban-meta-integration.md](../rayban-meta-integration.md) | 🔨 **POC fonctionnel — bien plus avancé que ce que cette ligne disait jusqu'au 2026-08-09.** Voir §5bis |
| VR Meta Quest (borne office tourisme) | [v2/vr-quest-unity-plan.md](../v2/vr-quest-unity-plan.md) · [roadmap.md](../roadmap.md) (section XR) | 📦 **V2** — ⚠️ **« zéro ligne de code » était faux** (corrigé le 2026-08-31, relevé dans le code). Le canal VR est déjà dans le modèle : `AppType.VR`, `Instance.IsVR` en base depuis juillet 2025, sous-menu « VR » branché dans `main_screen.dart:65`, filtre stats + i18n `statsChannelVR` prêts. **Ce qui manque** : l'écran de flotte (`main_screen.dart:704` = `Text("TODO vr")`), un `Device.AppType` (le `DeviceController` est hardcodé sur `Tablet`), et l'app Unity. Back-office XR : **8-12 j**. App Unity : **3-4 mois**. La landing annonce « Meta Quest — Bientôt » en 4 langues (`translations.ts`, clé `mode4Desc`) |

View File

@ -0,0 +1,161 @@
# Plan de test — Studio lots 6 à 8 (mention IA, calibrage, narration, casting)
> Écrit le 2026-09-15, pour une première passe le 16/09. **Rien de ce plan n'a encore tourné en réel** :
> tout est couvert par des tests unitaires (357 verts côté service) et l'analyse Flutter / TypeScript, mais
> aucun écran n'a été affiché et aucun audio n'a été généré. Les résultats viennent de Thomas, pas du code.
>
> Légende : colonne ✓ à remplir (✅ / ❌ + une note). Un ❌ dans § 0 arrête la suite.
> Référence : [v2/studio-plan.md](v2/studio-plan.md) § 9.4 (calibrage) et lot 8.
---
## 0. Prérequis — une fois
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 0.1 | `winget install Gyan.FFmpeg`, rouvrir le terminal, `ffmpeg -version` | Une version s'affiche. Sans ffmpeg, la génération audio échoue et les crédits sont remboursés | |
| 0.2 | `dotnet user-secrets set "AI:ApiKey" "<clé Gemini>" --project manager-service/ManagerService` | `dotnet user-secrets list` montre `AI:ApiKey` (et `Studio:Fal:Key`) | |
| 0.3 | Lancer Postgres puis `dotnet ef database update` avec `MIGRATIONS_CONNECTION` (voir la note mémoire EF) | Migrations appliquées : `AddAiWatermarkBurned`, `StudioCalibrationEngravingStyle`, `StudioCreditUnitPerReference`, `AddNarrators`, `AddGeoPointAudio`, `AddCasting` | |
| 0.4 | `dotnet run` (manager-service), `flutter run -d chrome` (manager-app) | Les deux démarrent, connexion SuperAdmin OK | |
| 0.5 | Dialogue d'instance (SuperAdmin) : assistant **et** Studio activés, **100 crédits** accordés | Solde 100 dans la jauge du menu | |
| 0.6 | Guide IA Personnages : un personnage **Léon** avec une **voix** (Umbriel) et une intonation, un second **La sentinelle** avec une autre voix (Kore) | Les deux apparaissent dans la liste, sans « (sans voix) » | |
| 0.7 | Donner un **portrait** à Léon (onglet Visage → générer ou choisir une vue Portrait) | La vue Portrait est enregistrée | |
---
## 1. Mention IA dans le fichier (lot 6)
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 1.1 | Studio : générer une image et la **valider** | L'image arrive dans la médiathèque avec la facette IA | |
| 1.2 | Télécharger le fichier validé, l'ouvrir dans un lecteur de métadonnées (ex. exiftool, ou Propriétés Détails) | XMP `DigitalSourceType = trainedAlgorithmicMedia`, droits = nom de l'instance | |
| 1.3 | Dialogue d'instance : cocher **Graver la mention IA**, générer et valider une nouvelle image | Bandeau « Généré par IA · AI-generated » en bas à droite de l'image. L'ancienne image n'a pas changé | |
| 1.4 | ⚠️ Enregistrer l'instance **depuis un autre écran** (ex. Abonnement) | La case gravure reste cochée : un enregistrement qui ne l'envoie pas ne la remet pas à faux | |
| 1.5 | Décocher la gravure | Les images suivantes n'ont plus de bandeau | |
---
## 2. Calibrage et unité de crédit
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 2.1 | Studio Identité : ouvrir la grille des styles | **8 tuiles avec un moulin**, un style chacune (plus aucune tuile vide) | |
| 2.2 | Guide IA Personnages Visage : grille des archétypes | **12 mannequins au trait**, un par pose | |
| 2.3 | Générer 3 variantes **sans image de référence** | Estimation et débit = **6 crédits** (2 par image) | |
| 2.4 | Ajouter 3 images de référence à l'identité, relancer 3 variantes | Estimation = **15 crédits** (3 × (2 + 3)) | |
| 2.5 | Style Gravure, gabarit « Décor d'énigme », 3 variantes | Images **noir et blanc**, **aucune légende ni signature** (c'était ~4/10 avant correction) | |
| 2.6 | Gabarit « Objet d'époque » depuis une photo d'objet **portant une date** | La date reste gravée sur l'objet | |
| 2.7 | Relire les planches `outputs/studio-calibrage-2026-09-15/` | Ton verdict « du même projet » par série, à reporter dans studio-plan § 9.4 | |
---
## 3. Qui raconte quoi (lot 8a et 8c)
Préparer une configuration avec un **parcours de 4 étapes** (titre + description remplis en FR et NL) et un **article** avec du texte.
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 3.1 | Écran Configuration : carte **Narration** visible | Deux champs : « Guide de cette visite » et « Narrateur par défaut de la visite » | |
| 3.2 | Guide de la visite = *Guide de l'instance*, narrateur par défaut = *Le guide raconte*, **Enregistrer** | Enregistré ; recharger l'écran garde les valeurs | |
| 3.3 | Ouvrir le parcours : dans le rail, une entrée **Narrateurs** sous « Parcours » | L'onglet s'ouvre, une ligne par étape | |
| 3.4 | Sans rien choisir | Chaque étape affiche en gris « ↳ » + le guide de l'instance, ou « Aucun narrateur » s'il n'y en a pas | |
| 3.5 | Narrateur par défaut du parcours = **Léon** | Toutes les lignes passent à « ↳ Léon » en gris | |
| 3.6 | Étape 3 : choisir **La sentinelle** | La ligne 3 affiche La sentinelle **en gras** ; les autres restent « ↳ Léon » | |
| 3.7 | Fermer et rouvrir le parcours | Le choix de l'étape 3 et le défaut sont conservés | |
| 3.8 | **Toutes les étapes suivent le défaut** | Confirmation « Retirer le narrateur choisi sur 1 étape(s) ? », puis l'étape 3 repasse à « ↳ Léon » | |
| 3.9 | Remettre La sentinelle sur l'étape 3 (pour la suite) | — | |
| 3.10 | ⚠️ Archiver La sentinelle (Personnages) puis revenir à l'onglet | L'étape 3 retombe sur « ↳ Léon » : un personnage archivé ne parle plus | |
| 3.11 | Désarchiver La sentinelle | L'étape 3 la retrouve | |
| 3.12 | Instance **sans Studio** (décocher dans le dialogue) | L'entrée Narrateurs disparaît du rail et le bloc Narration de l'article aussi. Réactiver ensuite | |
---
## 4. Générer l'audio (lot 8b et 8c)
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 4.1 | Onglet Narrateurs : colonne Audio | « 2 à générer · N crédits » par étape (FR + NL) ; barre du bas « X fichier(s) audio à générer · Y crédits » | |
| 4.2 | **Générer l'audio** | Confirmation qui répète le total. Rien ne part avant « Oui » | |
| 4.3 | Confirmer | Notification « Génération lancée » ; en moins d'une minute les lignes passent à **Audio à jour** sans rien toucher (relecture toutes les 5 s) | |
| 4.4 | Jauge de crédits | Débit ≈ **1 crédit par minute d'audio par langue**, jamais plus que l'estimation affichée | |
| 4.5 | Médiathèque, filtre Audio | Un MP3 par étape et par langue, libellé « Léon · titre (FR) », facette IA | |
| 4.6 | Écouter un MP3 (étape 3) | Voix de **La sentinelle**, dans la bonne langue, avec l'intonation du personnage | |
| 4.7 | ⚠️ **Piège corrigé** : modifier le **titre** d'une étape juste après la génération, attendre l'enregistrement, rouvrir | L'audio de l'étape est **toujours rattaché** (avant correction, l'enregistrement le détachait) | |
| 4.8 | Revenir à l'onglet Narrateurs | L'étape modifiée est « 2 à régénérer » : son texte a changé | |
| 4.9 | Changer la voix de Léon (Personnages Voix) | Toutes les étapes de Léon passent « à régénérer » ; celle de La sentinelle reste à jour | |
| 4.10 | Régénérer | L'ancien MP3 **disparaît** de la médiathèque, le nouveau le remplace | |
| 4.11 | Étape avec un **audio déposé à la main** en FR, puis générer FR | L'audio généré prend sa place dans l'étape, **le fichier déposé reste** dans la médiathèque | |
| 4.12 | Article : bloc **Narration audio** sous les réglages audio | Narrateur « ↳ Léon » (hérité), état, bouton Générer | |
| 4.12b | Carte : réglages de la section, **Narrateur par défaut des points** = La sentinelle, enregistrer la section | Chaque fiche de point affiche « ↳ La sentinelle » dans son bloc Narration audio | |
| 4.12c | Carte : ouvrir un point, choisir **Léon** dans son bloc Narration audio | Le point s'enregistre seul ; moins d'une seconde après, l'état se relit (Léon, « à générer ») | |
| 4.12d | Carte : générer l'audio d'un point, puis modifier son titre | L'audio reste rattaché au point après rechargement ; l'état passe « à régénérer » | |
| 4.12e | web : ouvrir ce point sur la carte | Lecteur audio en tête de la fiche, portrait + nom du narrateur au-dessus (lot 8e) | |
| 4.12f | mymuseum-visitapp : ouvrir ce point | Lecteur audio au-dessus de la description, portrait + nom | |
| 4.12g | tablet-app : ouvrir ce point | Portrait + nom à gauche du bouton de lecture | |
| 4.12h | Les trois : langue sans audio pour ce point | Pas de lecteur sur mobile et tablette (pas de repli de langue) ; le web retombe sur la première langue, comme pour l'article | |
| 4.13 | Article : générer, puis **Enregistrer** la section après le passage à « Audio à jour » | L'audio reste sur l'article après rechargement | |
| 4.14 | Personnage **sans voix** en narrateur, Générer | Message « Ce narrateur n'a pas de voix », aucun crédit réservé | |
| 4.15 | Solde à 0, Générer | Message « Crédits Studio insuffisants » | |
| 4.16 | Couper le réseau du **serveur** vers Gemini (ou mauvaise clé), Générer | Les lignes ne passent jamais à jour, et les crédits réservés sont **remboursés** (jauge) | |
---
## 5. Côté visiteur — le portrait du narrateur (lot 8d)
Même configuration, avec l'audio généré en § 4. Léon a un portrait, La sentinelle non.
| # | App | Étape | Attendu | ✓ |
|---|---|---|---|---|
| 5.1 | visitapp-web | Ouvrir l'**article** | Le lecteur audio joue ; à la place de « GUIDE AUDIO », **portrait + « Léon »** | |
| 5.2 | visitapp-web | Parcours **en liste**, étapes 1 et 3 | L'audio **joue** (avant, un audio rangé par identifiant n'avait pas de lecteur) ; portrait de Léon à l'étape 1, disque neutre + « La sentinelle » à l'étape 3 | |
| 5.3 | visitapp-web | Parcours **en carte** (`showMap`) | Même chose dans la vue carte | |
| 5.4 | visitapp-web | Changer la langue en NL | L'audio NL joue, même narrateur | |
| 5.5 | mymuseum-visitapp (en ligne) | Article | Lecteur replié à droite ; déplié, le **portrait** ouvre la ligne | |
| 5.6 | mymuseum-visitapp (en ligne) | Parcours, vue contenu **et** vue carte | L'audio de l'étape joue, portrait + nom au-dessus du lecteur | |
| 5.7 | mymuseum-visitapp (**hors ligne**) | Article téléchargé, mode avion | L'audio joue ; **pas de portrait** (limite connue, il n'est pas téléchargé) | |
| 5.8 | tablet-app | Article | Portrait + nom à gauche du bouton de lecture | |
| 5.9 | Les trois | Un audio **déposé à la main** (pas une narration) | Lecteur habituel, **aucun** portrait | |
| 5.10 | Les trois | Une ancienne étape dont l'audio est une **URL** | L'audio joue toujours | |
---
## 5bis. Casting — « les personnes que vous allez rencontrer » (lot 8bis)
Migration supplémentaire à appliquer : `AddCasting`. Même configuration qu'en § 3 (Léon et La sentinelle avec portrait,
ajouter **La cuisinière sans portrait** comme narratrice d'une étape).
| # | App | Étape | Attendu | ✓ |
|---|---|---|---|---|
| 5b.1 | manager | Configuration **sans aucun narrateur ni guide** | Carte « Personnages de la visite » : « Aucun personnage ne raconte cette visite » | |
| 5b.2 | manager | Seul le guide (Léon) raconte | « Léon raconte toute cette visite (le guide) », **ni liste ni interrupteur** | |
| 5b.3 | manager | Étape 3 = La sentinelle, étape 4 = La cuisinière | Liste de 3 : Léon en **gris** « ↳ » (hérité), les deux autres en gras, avec « N étape(s) » ; ordre = ordre d'apparition dans la visite | |
| 5b.4 | manager | Ligne La cuisinière | « Pas de portrait : ne peut pas être montré », case grisée | |
| 5b.5 | manager | Réordonner (glisser La sentinelle en tête), décocher Léon, armer « Présenter les personnages au visiteur », **Enregistrer**, recharger | Ordre, cases et interrupteur conservés | |
| 5b.6 | manager | Toucher l'ordre puis **Annuler** | Retour à l'état enregistré | |
| 5b.7 | web | Ouvrir la visite (Léon décoché) | **Pas** d'écran d'entrée : un seul portrait montré | |
| 5b.8 | manager + web | Recocher Léon, enregistrer, rouvrir la visite (attendre ~1 min, cache) | Écran d'entrée : La sentinelle puis Léon, portraits ronds, bouton « Commencer la visite » | |
| 5b.9 | web | Fermer, revenir à la liste des sections | L'écran ne revient pas dans la même session ; un nouvel onglet le remontre | |
| 5b.10 | web | Passer en NL | Titre « De personen die u zult ontmoeten » | |
| 5b.11 | mymuseum-visitapp | Ouvrir la visite | Même écran, une fois par lancement de l'app | |
| 5b.12 | tablet-app | Charger la configuration | Même écran au chargement | |
| 5b.13 | manager | Désarmer l'interrupteur, enregistrer | Plus aucun écran d'entrée sur les trois apps | |
---
## 6. Non-régression
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 6.1 | Guide IA (assistant) : poser une question sur un contenu dont le **titre** contient du gras ou un `&` | Réponse normale, titre cité sans balises ni `&amp;` (le nettoyage HTML de l'assistant est désormais partagé avec la narration) | |
| 6.2 | Image générée côté visiteur | Mention « Image générée par IA » toujours présente (lot 6) | |
| 6.3 | Enregistrer un article **sans** toucher à la narration | Rien ne change dans ses audios | |
| 6.4 | Modifier un point de carte dans l'ancienne fenêtre | Enregistrement OK ; son audio éventuel n'est pas effacé | |
---
## Limites connues (pas des bugs)
- Les **points de carte** n'ont pas encore d'assignation de narrateur ni de bouton Générer dans le manager.
- Hors ligne, mymuseum-visitapp ne montre pas le portrait.
- Le portrait affiché est le portrait **actuel** du personnage ; le nom et la voix sont ceux de l'audio entendu.
- Les extraits d'écoute des voix (lot 7) ne sont pas encore produits.

View File

@ -51,6 +51,10 @@ tests fonctionnels ci-dessous.
20. [Écarts de parité connus — à rejouer après correction](#20-écarts-de-parité-connus--à-rejouer-après-correction)
21. [Visite hors ligne — diagnostic terrain](#21-visite-hors-ligne--diagnostic-terrain) ⚠️ **bugs suspectés, à jouer tôt**
23. [QR codes, nom d'application, page de téléchargement, proximité](#23-qr-codes-nom-dapplication-page-de-téléchargement-proximité)
24. [Onglet XR — flotte de casques (lot XR-2)](#24-onglet-xr--flotte-de-casques-lot-xr-2)
25. [App VR — appairage et lecture du contenu (XR-4, items E2-E3)](#25-app-vr--appairage-et-lecture-du-contenu-xr-4-items-e2-e3) ⚠️ **jamais exécuté**
26. [Médias immersifs dans le manager — ce que voit le gestionnaire (XR-4)](#26-médias-immersifs-dans-le-manager--ce-que-voit-le-gestionnaire-xr-4)
27. [Hors ligne complet et fond immersif (XR-4, E4 et E5)](#27-hors-ligne-complet-et-fond-immersif-xr-4-e4-et-e5) ⚠️ **jamais exécuté**
---
@ -979,11 +983,451 @@ Prérequis : migration `AddAppNameQrAndStoresToApplicationInstance` appliquée ;
---
## 24. Onglet XR — flotte de casques (lot XR-2)
Livré le 2026-09-12. **Aucun casque ne sait encore s'appairer**`E2` de `XR-4` n'est pas fait —
donc l'appareil de test se crée à la main par l'API. C'est la seule façon de voir la carte d'un
casque appairé aujourd'hui, et ça teste exactement le chemin que prendra Unity.
### 24.0 — Prérequis, à faire une fois
Il n'y a **pas encore d'écran** pour activer le canal VR sur une instance (carte kanban « Écran
SuperAdmin — add-on IA & canaux », planifiée). Tout se fait par l'API, en SuperAdmin.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.0.1 | `PUT /api/instance` avec `isVR: true` sur l'instance de test | 200, et `isVR` toujours à `true` après un `GET` | |
| 24.0.2 | `POST /api/applicationinstance` avec `appType: 3` et l'`instanceId` | 200 — sans elle, l'onglet affiche « Application VR non configurée » | |
| 24.0.3 | Créer un **lien de configuration** sur cette `ApplicationInstance` VR (depuis l'onglet Configuration, une fois 24.0.2 fait) | Le lien existe — il servira de carte « aucun casque appairé » | |
| 24.0.4 | `POST /api/device` avec `appType: 3`, un `identifier` bidon, l'`instanceId` et un `configurationId` valide | 200. ⚠️ Deux pièges : **sans `appType`, le serveur crée une tablette** (elle irait dans l'onglet Kiosk), et l'appel **crée son propre `AppConfigurationLink`** — il ne se rattache pas au lien de 24.0.3, on se retrouve donc avec deux cartes | |
### 24.1 — Le menu
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.1.1 | Ouvrir le manager sur l'instance de test | Entrée « VR » sous Applications, à côté de Mobile / Kiosk / Web | |
| 24.1.2 | Basculer sur une instance **sans** canal VR | L'entrée « VR » disparaît, et l'écran affiché n'est pas resté celui de l'instance précédente | |
| 24.1.3 | Instance avec `isVR: true` mais **sans** `ApplicationInstance` VR (sauter 24.0.2) | « Application VR non configurée », pas d'écran vide ni de crash | |
### 24.2 — Sous-onglet « Configuration »
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.2.1 | Ouvrir l'onglet VR | Deux sous-onglets, « Configuration » actif, souligné | |
| 24.2.2 | Modifier les **langues** de l'application VR, recharger | Conservées, et **sans effet** sur les langues de Mobile / Web / Kiosk | |
| 24.2.3 | Ajouter un lien de configuration depuis cet onglet | Apparaît dans la liste, et dans le sous-onglet « Casques » | |
| 24.2.4 | Comparer à l'onglet Mobile | Mêmes champs, même comportement — c'est le même écran réutilisé | |
### 24.3 — Sous-onglet « Casques »
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.3.1 | Ouvrir « Casques » | Le **code PIN** de l'instance en tête, puis la grille — **deux cartes**, celle du lien de 24.0.3 et celle créée par le `POST` de 24.0.4 | |
| 24.3.2 | La carte du lien de 24.0.3 | « Aucun casque appairé » + le nom de la configuration | |
| 24.3.3 | La carte du casque créé en 24.0.4 | Son nom (ou son identifiant si sans nom), le nom de la configuration, une pastille **rouge** (jamais connecté) | |
| 24.3.4 | Mettre `connected: true` sur ce device par l'API, revenir sur l'onglet | Pastille **verte** | |
| 24.3.5 | Renseigner `batteryLevel`, `appVersion`, `lastSeen` par l'API, recharger | Les trois lignes s'affichent sous la configuration ; la date est au format `jj/mm/aaaa hh:mm` | |
| 24.3.6 | Device **sans** `lastSeen` | « Jamais vu », pas de ligne vide ni de date à 1970 | |
| 24.3.7 | Bouton crayon → changer le nom et la configuration → valider | Notification de succès, la carte se met à jour **sans** recharger la page | |
| 24.3.8 | Recharger après 24.3.7 | Le nom **et** la configuration ont tenu — les deux passent par deux appels distincts (`device/mainInfos` puis le lien de configuration) | |
| 24.3.9 | Revenir sur « Configuration » puis sur « Casques » | La configuration réassignée en 24.3.7 est bien celle affichée des deux côtés | |
| 24.3.10 | Ouvrir l'onglet **Kiosk** de la même instance | Le casque de test **n'y apparaît pas**, et les tablettes existantes y sont toujours toutes | |
### 24.4 — Langues et affichage
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 24.4.1 | Basculer le manager en EN puis en NL | Titres d'onglets, « Casques », « Aucun casque appairé », « Batterie / Version / Vu le » traduits — aucun texte français résiduel | |
| 24.4.2 | Réduire la fenêtre à une largeur de tablette | La grille repasse en moins de colonnes, rien n'est tronqué en hauteur dans les cartes | |
| 24.4.3 | Un casque au nom très long | Ellipse, pas de débordement | |
### 24.5 — Ce que ce plan ne teste pas
Appairage réel par pincode depuis le casque, remontée automatique de batterie et de version,
rafraîchissement MQTT : tout cela est dans `XR-4` (`E2`) et `XR-5`, pas encore écrit. Les valeurs
de 24.3.5 sont posées à la main précisément parce que rien ne les alimente encore.
---
## 25. App VR — appairage et lecture du contenu (XR-4, items E2-E3)
Écrit le 2026-09-12, **jamais exécuté** : ni contre un vrai serveur, ni sur le casque. Le code
compile contre les DLL Unity, c'est tout ce qu'on sait. Ce chapitre est donc une **première
exécution**, pas une non-régression — traiter chaque échec comme une information, pas comme un bug.
Prérequis : le §24.0 joué (canal VR activé sur l'instance de test), un Quest en mode développeur,
et la scène construite par **MyInfoMate → Construire la scène Boot (E2-E3 — appairage et export)**.
Le code PIN, l'URL du serveur et la langue se posent **dans l'inspecteur du `Bootstrap`** avant le
build : sans l'Interaction SDK (E1) il n'y a ni pointeur ni clavier dans le casque.
### 25.1 — Appairage (E2)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.1.1 | PIN correct, canal VR activé, Build And Run | Le casque affiche le **nom de la configuration** et ses sections. Console : `[Pairing] Casque appairé — device …` | |
| 25.1.2 | Ouvrir l'onglet XR du manager après 25.1.1 | Le casque **apparaît dans la grille**, nom = celui du champ `headsetName`, pastille verte | |
| 25.1.3 | Ouvrir l'onglet **Kiosk** de la même instance | Le casque **n'y est pas** — c'est tout l'objet de `appType = 3` | |
| 25.1.4 | Relancer l'app sans rien changer | **Aucun nouvel appareil** dans le manager : l'identifiant matériel est stable et le serveur reconnaît l'appareil | |
| 25.1.5 | Relancer avec un PIN vidé | Toujours appairé (persistance `PlayerPrefs`), le PIN n'est plus lu | |
| 25.1.6 | Cocher `forgetPairing`, rebuild | Réappairage complet ; vérifier qu'il n'y a **pas** de second appareil dans le manager | |
| 25.1.7 | PIN inexistant | « Ce code PIN ne correspond à aucun lieu. » — pas d'écran noir | |
| 25.1.8 | PIN d'une instance **sans** canal VR (sauter 24.0.2) | « Le canal VR n'est pas activé pour ce lieu. » ⚠️ C'est un 404 traduit : si le message générique « Ce contenu n'existe plus » apparaît, la traduction du 404 est à revoir | |
| 25.1.9 | URL de serveur injoignable (couper le wifi avant le lancement) | « Le serveur est injoignable. Vérifiez la connexion du casque. » au bout de ~20 s, pas un gel | |
### 25.2 — Lecture du contenu (E3)
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.2.1 | Instance avec plusieurs sections actives | Le message liste **jusqu'à 5 sections** avec leur type ; la console (`adb logcat`) les liste **toutes** | |
| 25.2.2 | Comparer à l'écran Configurations du manager | Même nombre de sections de premier niveau, même ordre. Les sous-sections d'un Menu **ne comptent pas** | |
| 25.2.3 | Une section désactivée dans le manager | Elle n'apparaît pas dans le casque | |
| 25.2.4 | Passer `language` de FR à EN, rebuild | Les titres changent de langue. ⚠️ Si une section n'a pas de traduction EN, elle retombe sur la première disponible — c'est voulu, pas un bug | |
| 25.2.5 | Une configuration contenant **tous** les types de section (Game, PDF, Weather…) | **Rien ne plante** : les 13 types se lisent, même ceux que la VR ne rendra jamais | |
| 25.2.6 | Configuration vide (aucune section) | « 0 sections », pas d'erreur | |
| 25.2.7 | ⚠️ **Ouvrir le JSON d'export d'une vraie configuration** (navigateur, avec la clé) | Il porte `exportVersion: 1`, `generatedAt`, **et les champs spécifiques de chaque section** — les `contents` d'un Slider, les `points` d'une Map, le `model3DResourceId` d'une maquette. Jusqu'au 12/09 il ne portait que les champs communs, et **l'export est aussi ce qui part en visite hors ligne** | |
| 25.2.8 | Sur ce même JSON, chercher une section multilingue | Les titres portent **toutes** les langues, pas seulement une : le casque change de langue sans rappeler le serveur | |
| 25.2.9 | ⚠️ **mymuseum-visitapp** : télécharger une visite hors ligne, couper le réseau, l'ouvrir | Les galeries, cartes et quiz ont leur contenu. C'est le même correctif d'export : à vérifier **avant** de croire que seul le casque était concerné | |
### 25.3 — Cache et mode hors ligne (E4)
Le cache s'écrit dès le premier chargement réussi. C'est l'argument n°1 d'Unity contre le web :
si ces cas-là ne passent pas, le choix de stack perd sa justification.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.3.1 | Lancer une première fois avec le wifi | Contenu affiché, et un fichier apparaît dans `/sdcard/Android/data/<package>/files/content/` (`adb shell ls`) | |
| 25.3.2 | **Couper le wifi du casque**, relancer l'app | Le contenu s'affiche **quand même**, avec la ligne « hors ligne — contenu du jj/mm hh:mm » | |
| 25.3.3 | Comparer le délai d'affichage avec et sans wifi | Le départ sur cache doit être **plus rapide** que le premier lancement : c'est tout l'objet du « cache d'abord » | |
| 25.3.4 | Modifier un titre de section dans le manager, relancer l'app (wifi actif) | L'ancien contenu s'affiche d'abord, puis **se remplace** par le nouveau sans relancer — console : `[Content] Contenu mis à jour depuis le serveur` | |
| 25.3.5 | Même chose sans modification | **Aucun clignotement** : le contenu identique ne doit pas provoquer de réaffichage | |
| 25.3.6 | Éteindre brutalement le casque pendant un rafraîchissement, relancer | Le contenu précédent est intact — l'écriture est atomique, jamais un JSON tronqué | |
| 25.3.7 | Wifi coupé **et** cache vidé (désinstaller/réinstaller) | « Le serveur est injoignable… », pas un écran noir | |
### 25.4 — Télémétrie (E9)
Rien n'émet encore d'événement automatiquement : `Telemetry` existe mais n'est appelée que
depuis le menu (E5), pas encore écrit. À jouer **après E5**, ou en déclenchant un appel à la main.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.4.1 | Émettre un `SectionView` depuis le casque | Écran Statistiques du manager : le canal **VR** apparaît dans le filtre, sans aucune configuration | |
| 25.4.2 | Vérifier le canal de l'événement en base | `AppType = 3`. ⚠️ S'il vaut `0` (Mobile), c'est le parsing par nom qui a échoué **en silence** — le piège documenté du `StatsController` | |
| 25.4.3 | Émettre pendant que le wifi est coupé | L'app continue normalement, la console note l'échec, **rien ne remonte au visiteur** | |
### 25.5 — Menu flottant : manette, main, tête (E5)
Scène **MyInfoMate → Construire la scène Boot (E5 — menu flottant)**. C'est l'app : elle appaire,
sert le cache, affiche le menu et émet la télémétrie. Les cas 25.1 à 25.4 valent aussi pour elle.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.5.1 | Lancer avec une configuration de 3-4 sections | Un **arc de panneaux** à ~2,2 m, à hauteur d'yeux, chacun de face | |
| 25.5.2 | Tourner la tête | Le menu **reste où il est** — un menu qui suit la tête est invisable et donne la nausée | |
| 25.5.3 | **Manettes posées, mains baissées.** Viser un panneau avec la tête | Il s'éclaircit, et un **anneau se remplit** en ~1,2 s. Pas de rayon visible — c'est voulu, un trait au centre du champ de vision gêne en permanence | |
| 25.5.4 | Détourner la tête avant la fin | L'anneau se vide, **rien ne se déclenche** | |
| 25.5.5 | Maintenir jusqu'au bout | Le titre de la section s'affiche. ⚠️ Vérifier que c'est **la bonne** — c'est tout l'objet du test | |
| 25.5.6 | Balayer le menu de la tête, sans s'arrêter | Aucun déclenchement accidentel. Si ça part trop vite, allonger `dwellSeconds` | |
| 25.5.7 | **Prendre une manette.** Viser | Un **rayon apparaît**, le panneau visé s'éclaircit, et l'anneau **ne se remplit pas** | |
| 25.5.8 | Appuyer sur la gâchette d'index | Déclenchement **immédiat**, sans attente | |
| 25.5.9 | Reposer la manette, reprendre la tête | Le rayon disparaît et la temporisation revient — la bascule est automatique, sans réglage | |
| 25.5.10 | **Mains nues** (building block *Hand Tracking* dans la scène) | Rayon depuis la main, déclenchement au **pincement pouce-index** | |
| 25.5.11 | Maintenir le pincement | **Un seul** déclenchement, pas un par image | |
| 25.5.12 | Sans le building block *Hand Tracking* | Manette et tête fonctionnent quand même, **aucune erreur** en console | |
| 25.5.13 | Configuration de **plus de 5 sections** | Une seconde rangée en dessous, pas un cercle autour du visiteur | |
| 25.5.14 | Sections avec image dans le manager | Les vignettes apparaissent **après** le menu, sans le bloquer | |
| 25.5.15 | Section sans image | Le panneau reste lisible, titre seul | |
| 25.5.16 | Relancer hors ligne après un premier lancement | Menu **et** vignettes viennent du cache | |
| 25.5.17 | Après 25.5.5, regarder les stats du manager | `MenuItemTap` et `SectionView` sur le canal VR (voir 25.4) | |
⚠️ **Deux réglages à rapporter**, ils ne se décident pas sur le papier :
| # | Réglage | Question | Valeur retenue |
|---|---------|----------|----------------|
| 25.5.18 | `dwellSeconds` (1,2 s) | Pénible, ou trop prompt à déclencher ? | |
| 25.5.19 | Distance et hauteur de l'arc (2,2 m / 1,45 m) | Texte lisible sans effort ? Panneaux hauts atteignables sans lever la tête ? | |
### 25.6 — Sections affichables : Slider, Map, Parcours, Event (E8)
Les quatre types qui s'ouvrent, tous dans la **même vue paginée** — une chose à la fois, devant
soi, avec de quoi passer à la suivante. Prérequis : une configuration de test qui contient un
Slider (3+ images), une Map (3+ points), un Event avec du programme **aujourd'hui**, et au moins
un type écarté (Article ou Quiz).
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.1 | Choisir un Slider dans le menu | Le menu **disparaît**, une grande image apparaît à 2,5 m avec « 1 / N » | |
| 25.6.2 | Galerie de 10+ images | La **première s'affiche sans attendre** les suivantes ; le compteur monte au fur et à mesure | |
| 25.6.3 | Viser ▶ | Image suivante, compteur à jour, légende à jour | |
| 25.6.4 | Arriver à la dernière image | La flèche ▶ **disparaît** — un bouton présent qui ne réagit pas se lit comme une panne | |
| 25.6.5 | Première image | Idem pour ◀ | |
| 25.6.6 | « Retour au menu » | La galerie disparaît, le menu **revient tel qu'il était** (même position, mêmes vignettes) | |
| 25.6.7 | Après 25.6.6, stats du manager | Un `SectionLeave` avec une **durée** cohérente avec le temps passé | |
| 25.6.8 | Slider dont les contenus ont un ordre défini dans le manager | Même ordre dans le casque | |
| 25.6.9 | Slider sans image, ou dont les images ne se téléchargent pas | « Cette galerie n'a aucune image affichable. », pas un cadre vide | |
| 25.6.10 | Rouvrir le même Slider, wifi coupé | Images servies depuis le cache | |
| 25.6.11 | Choisir une section **non gérée** (Article, Quiz…) | Le titre s'affiche avec « pas encore affichable », et on peut revenir | |
**Map et Parcours** — rendus en **liste de points d'intérêt**, un par page :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.12 | Ouvrir une Map | Un POI par page : titre, description, horaires s'il y en a, photo s'il y en a | |
| 25.6.13 | Comparer à la liste de la Map dans le manager | Mêmes points, aucun oublié | |
| 25.6.14 | POI sans photo | Le cadre reste sombre et le **texte tient tout seul**, pas de trou | |
| 25.6.15 | Ouvrir un Parcours (Map avec `isParcours`) | Même rendu qu'une Map — c'est assumé, voir ci-dessous | |
**Event** — le programme, une page par bloc :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.6.16 | Event avec du programme **aujourd'hui** | Seuls les blocs du jour, triés par heure, avec « 14:00 15:30 » | |
| 25.6.17 | Event dont le programme du jour est **vide** | Les **dates suivantes** s'affichent, au format « 14/09 · 14:00 » | |
| 25.6.18 | Event entièrement passé | « Rien au programme aujourd'hui. », pas un cadre vide | |
⚠️ **Trois choses à juger sur place, pas sur le papier :**
| # | Question | Impression |
|---|----------|------------|
| 25.6.19 | L'image fait 1,6 × 0,9 m à 2,5 m. Trop grande, elle oblige à balayer la tête ; trop petite, elle ne vaut pas mieux qu'une tablette | |
| 25.6.20 | Une **Map en liste de POI** est-elle acceptable en démo, ou est-ce que ça se voit trop que la maquette 3D manque ? | |
| 25.6.21 | Un **Parcours rendu comme une Map** passe-t-il, ou faut-il le masquer du menu tant qu'il n'a pas son vrai rendu ? | |
### 25.7 — Ressource 360° et lecture immersive (E6)
Deux moitiés : déclarer une 360 dans le manager, puis la voir dans le casque. La première se
teste sans casque — à faire dès maintenant si l'occasion se présente.
**Dans manager-app :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.7.1 | Médiathèque → ajouter un `.jpg` | Pastille « Image », comme avant | |
| 25.7.2 | **Toucher la pastille** | Elle bascule en « Image 360° » et se colore | |
| 25.7.3 | Envoyer, puis rouvrir la fiche du média | Type conservé après rechargement | |
| 25.7.4 | ⚠️ **Vérifier les dimensions du fichier stocké** (fiche du média, ou Firebase) | **La 360 n'est PAS redimensionnée** : une 8192×4096 reste 8192×4096. Si elle est ressortie en 2560 de large, l'exclusion de compression n'a pas pris — et dans le casque ce sera une bouillie | |
| 25.7.5 | Même chose avec un `.jpg` laissé en « Image » | Celui-là **est** compressé à 2560 : la compression ordinaire n'a pas été cassée au passage | |
| 25.7.6 | Un `.mp4` basculé en « Vidéo 360° » | Type conservé, fichier intact | |
| 25.7.7 | Déposer un `.glb` | Accepté, type « Modèle 3D » **déduit tout seul** | |
| 25.7.8 | Remplacer le fichier d'une ressource 360 existante | Toujours pas de compression | |
| 25.7.9 | Passer le manager en EN puis NL | Libellés et infobulle traduits | |
**Dans le casque** (une `SectionVideo` dont le média est la ressource 360) :
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.7.10 | Choisir la section depuis le menu | Le décor **devient** la photo : on tourne la tête, on est dedans | |
| 25.7.11 | ⚠️ Regarder la couleur | Si le ciel est **magenta**, `Skybox/Panoramic` n'est pas dans *Always Included Shaders* — ça ne se voit **que** sur le casque, jamais dans l'éditeur | |
| 25.7.12 | Chercher la couture verticale du panorama | Pas de trait net à la jonction | |
| 25.7.13 | Juger la définition | C'est ici que se voit une 360 qui aurait été compressée | |
| 25.7.14 | **Enchaîner cinq ou six 360 d'affilée**, en revenant au menu entre chaque | L'app ne ralentit pas et **ne se fait pas tuer** : chaque image est libérée en sortant. Une fuite ne se voit qu'au bout de plusieurs, jamais sur un essai unique | |
| 25.7.15 | Section en **vidéo** 360 | Elle démarre, tourne en boucle, le son sort | |
| 25.7.16 | **Attendre 4 s sans rien faire** | Le panneau de retour **s'efface** : il ne reste rien dans le décor | |
| 25.7.17 | **Baisser les yeux** | Il réapparaît, posé sous le regard courant | |
| 25.7.18 | Se retourner complètement, puis baisser les yeux | Il est **devant soi**, pas resté à sa position de départ | |
| 25.7.19 | Prendre une manette | Il réapparaît sans avoir à baisser la tête | |
| 25.7.20 | ⚠️ **Sans rien savoir, sauriez-vous ressortir ?** Faire essayer quelqu'un qui n'a pas lu ce plan | S'il reste coincé dans la photo, 4 s d'affichage initial ne suffisent pas — c'est le réglage à revoir | |
| 25.7.21 | Le retirer | Le menu revient **sur le décor d'origine**, pas sur la 360 restée affichée | |
| 25.7.22 | Rouvrir une autre 360 puis revenir | Idem, aucun décor résiduel | |
| 25.7.23 | Relancer hors ligne | La 360 vient du cache disque | |
| 25.7.24 | Section Video pointant une **URL YouTube** | Pas de skybox — elle n'est pas téléchargeable, c'est traité comme un type non géré | |
### 25.8 — Maquette 3D et points d'intérêt (E7)
Trois endroits à vérifier : la section dans le manager, le viewer qui sert d'éditeur, et le rendu
dans le casque. ⚠️ **Le viewer n'a encore jamais tourné** — le lancer seul (`npm run dev`, glisser un
GLB) avant de juger l'intégration évitera de confondre deux problèmes.
**Dans manager-app :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.8.1 | Créer une section, groupe « lieux » | Le type **Scène 3D** est proposé, avec son icône | |
| 25.8.1b | Choisir le mode **Objet** puis **Décor** | L'explication sous le sélecteur change ; le choix tient après enregistrement | |
| 25.8.2 | Ouvrir la section | Le sélecteur de modèle ne propose **que** des ressources `Model3D` | |
| 25.8.3 | Sans modèle choisi | « Choisissez d'abord un modèle 3D » — pas d'iframe vide | |
| 25.8.4 | Choisir un `.glb` | Le viewer apparaît et **charge le modèle** | |
| 25.8.5 | Le viewer ne se charge pas | Vérifier l'URL : il doit être servi sous le même domaine que le manager (`/viewer/index.html`), ou lancé avec `--dart-define=VIEWER_URL=…` | |
| 25.8.6 | Ajouter des points d'intérêt à la section | Ils apparaissent dans le viewer, à l'origine du modèle | |
| 25.8.7 | Glisser un point, **Alt+glisser** pour la hauteur | Sa position suit | |
| 25.8.8 | Enregistrer, recharger l'écran | Les points sont **là où on les a mis** | |
| 25.8.9 | Vérifier en base | `Model3DPosition` renseigné en jsonb, `Geometry` **inchangé** — les deux positions coexistent | |
**Dans le casque :**
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.8.10 | Ouvrir une scène en mode **Objet** | Le modèle est **posé devant** le visiteur, à hauteur d'yeux ; on tourne autour sans se déplacer | |
| 25.8.10b | Ouvrir une scène en mode **Décor** | Le visiteur est **dedans** et regarde autour. ⚠️ Si en mode Objet on se retrouve *dans* le modèle, le mode n'est pas lu — c'est lui qui décide si le rig est passé au moteur | |
| 25.8.11 | ⚠️ **Le test qui compte : placer trois points à des endroits franchement asymétriques** | Ils sont **exactement là** dans le casque. Une maquette symétrique ne prouve rien — c'est le bug que `GltfSpace` et `calibration.glb` existent pour attraper | |
| 25.8.12 | Viser un point 1,2 s | Il s'éclaircit et **son audio part de lui**, pas du centre de la tête | |
| 25.8.13 | Maquette dont un point n'a pas de position 3D | Il est simplement absent, rien ne plante | |
| 25.8.14 | Modèle absent ou illisible | « Cette maquette n'a pas pu être chargée », retour au menu possible | |
| 25.8.15 | Mesurer le temps de chargement d'un GLB lourd | À rapporter : c'est ce qui dira s'il faut un écran de progression | |
### 25.9 — Mode borne (E10)
Ce qui sépare une démo d'une borne qu'on laisse tourner 8 h. **À jouer à deux**, en se passant le
casque : c'est exactement le scénario visé.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.9.1 | Retirer le casque, le reposer sur la table | Retour au menu **immédiat**, sans attendre le délai | |
| 25.9.2 | Le remettre, tourné dans une **autre direction** | Le menu est **devant soi**, pas dans le dos — c'est le cas qui casse une borne en salle | |
| 25.9.3 | Retirer le casque alors qu'une galerie était ouverte | Le visiteur suivant trouve le **menu**, pas la galerie du précédent | |
| 25.9.4 | Idem, puis regarder les stats | Un `SectionLeave` a bien été émis pour la galerie abandonnée | |
| 25.9.5 | Garder le casque sur la tête, immobile, **90 s** | Retour au menu | |
| 25.9.6 | Lire une longue légende sans bouger, moins de 90 s | **Pas** de retour au menu intempestif — si ça coupe, allonger `idleSeconds` | |
| 25.9.7 | Feuilleter une galerie pendant plus de 90 s, sans bouger la tête | **Pas** de retour au menu : choisir est une activité, même sans mouvement | |
| 25.9.8 | Après un retour au menu, stats du manager | Les événements suivants portent un **`sessionId` différent** — sinon une journée de borne compte pour un seul visiteur | |
| 25.9.9 | Laisser le casque porté, sans rien faire, 10 min | L'écran **ne s'éteint pas** | |
| 25.9.10 | Lancer dans le simulateur, ou dans l'éditeur | La session **ne se réinitialise pas** toutes les 90 s : sans capteur de présence, on considère le casque porté | |
⚠️ **Ce que E10 ne fait pas** : verrouiller le casque sur l'app. Ce n'est pas du code, c'est le mode
appareil de Meta — à régler sur le casque, et à revérifier avant de l'écrire dans une offre.
### 25.10 — Supervision de flotte (XR-5)
Ce qui remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 25.10.1 | Lancer l'app, puis ouvrir l'onglet XR | Le casque passe **en ligne tout de suite**, pas au bout de 3 min | |
| 25.10.2 | Recharger l'onglet après quelques minutes | **Batterie** cohérente avec celle du casque, **version** = celle du build, **dernier vu** qui avance | |
| 25.10.3 | Quitter l'app, attendre, recharger | Le « dernier vu » **se fige** — c'est ce qui permet de voir qu'un casque est tombé | |
| 25.10.4 | Couper le wifi, laisser tourner | L'app continue normalement, rien ne remonte au visiteur | |
| 25.10.5 | Vérifier en base après un battement | `LastSeen` et `AppVersion` renseignés — **ils n'avaient aucun chemin d'écriture avant** | |
| 25.10.6 | ⚠️ Forger un battement vers un casque d'**une autre instance** | **403** — une clé ne parle que de ses appareils | |
| 25.10.7 | ⚠️ **Appairer une vraie nouvelle tablette** (pas un casque) | Elle s'appaire. **Ça répondait 403 depuis mars** ; les tests ne le prouvent pas, car ils appellent le contrôleur sans passer par les filtres | |
| 25.10.8 | ⚠️ **Redémarrer une tablette déjà appairée** | Elle retrouve sa configuration. Deux causes corrigées ensemble le 12/09 : la route `Device/{id}/detail` refusait les clés, **et** l'app reconstruisait son client sans clé au démarrage | |
| 25.10.9 | Sur cette tablette, ouvrir une visite complète | Rien d'autre n'est retombé : c'est le chemin qui traverse le plus de routes fermées le 12/09 | |
| 25.10.10 | **Assigner une autre configuration au casque** depuis l'onglet XR, sans y toucher | Au bout de **3 min au plus**, le casque revient au menu **avec le nouveau contenu** — sans redémarrage. C'est le « pousser vers le casque » de XR-5, obtenu par le battement plutôt que par MQTT | |
| 25.10.11 | Faire ça pendant qu'une galerie est ouverte | Elle se ferme : le visiteur ne reste pas dans un contenu qui n'est plus le sien | |
| 25.10.12 | **Éteindre un casque**, revenir sur l'onglet XR une heure après | Bandeau rouge **« Muet depuis 1 h »** sur sa carte. ⚠️ La pastille reste verte — c'est le dernier battement qui l'a écrite : c'est précisément ce mensonge que le bandeau corrige | |
| 25.10.13 | Un casque vu il y a 10 minutes | **Pas** de bandeau : le seuil est à 1 h, soit vingt battements manqués | |
| 25.10.14 | Un casque muet depuis plus d'un jour | « Muet depuis N j », pas « depuis 34 h » | |
| 25.10.15 | ⚠️ **mymuseum-visitapp** : ouvrir un contenu dont la ressource **n'a pas d'URL** | L'image se charge quand même. Repéré dans le code sans pouvoir le déclencher : `apiService.dart:111` retombe sur `GET /api/Resource/{id}` en HTTP brut, **sans clé** — donc 401 depuis le 12/09. Le domaine y est en dur (`api.mymuseum.be`), donc ce repli ne marchait déjà plus ailleurs ; à supprimer si le cas n'existe pas | |
### 25.11 — Ce qu'on mesure au passage
Trois chiffres à rapporter, ils n'existent nulle part aujourd'hui :
| # | Mesure | Pourquoi | Valeur |
|---|--------|----------|--------|
| 25.11.1 | Temps entre le lancement et l'affichage des sections, avec et sans cache | Décide si un écran de progression est un détail ou un sujet | |
| 25.11.2 | Taille du JSON d'export d'une vraie configuration (`adb shell ls -l` sur le cache) | Dimensionne le cache disque, et dit si le stockage de l'add-on immersif tient | |
| 25.11.3 | Le casque garde-t-il le wifi en veille ? | Conditionne le rafraîchissement en tâche de fond et la supervision d'XR-5 | |
---
## 26. Médias immersifs dans le manager — ce que voit le gestionnaire (XR-4)
Ce chapitre ne teste ni le casque ni l'API : seulement qu'un conservateur qui n'a jamais entendu
parler de glTF **comprend ce qu'il manipule**. Les trois types immersifs existaient en base
depuis le 12/09 mais étaient invisibles côté Médiathèque — pas de facette, pas d'icône, pas
d'aperçu. Corrigé le même jour, jamais rejoué à l'écran.
Prérequis : une instance avec l'add-on immersif, et dans sa médiathèque au moins une image 360,
une vidéo 360 et un `.glb`.
### 26.1 — Médiathèque
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.1.1 | Ouvrir la Médiathèque | Le rail de facettes propose **Image 360°**, **Vidéo 360°** et **Modèle 3D** en fin de liste, avec leur compteur | |
| 26.1.2 | Cliquer la facette « Image 360° » | Seules les 360 restent ; le chip de filtre actif affiche le libellé traduit, pas un code | |
| 26.1.3 | Regarder les vignettes des trois types | Icônes **photosphère / 360 / cube**. Aucun point d'exclamation — c'était le cas par défaut, il se lisait comme une erreur de fichier | |
| 26.1.4 | Ouvrir une **image 360** | Aperçu visible (image équirectangulaire, donc déformée aux pôles — c'est normal) et non un cadre gris vide | |
| 26.1.5 | Ouvrir une **vidéo 360** | Le lecteur vidéo s'ouvre et la vidéo démarre | |
| 26.1.6 | Ouvrir un **modèle 3D** | Phrase explicite : pas d'aperçu ici, le modèle se visualise dans une section Scène 3D et dans le casque. **Pas** de cadre vide sans explication | |
| 26.1.7 | Sur ces trois-là, lire le bloc « Informations » | Type traduit, poids réel, date. Le poids d'une vidéo 360 est en Go — c'est l'information qui justifie le quota | |
| 26.1.8 | Téléverser un `.glb` | Il arrive en **Modèle 3D**, et n'est **pas** compressé (une compression le détruirait) | |
### 26.2 — Déclarer qu'une image est une 360
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.2.1 | Dans un sélecteur de médias, regarder la pastille de type d'une image | Un petit **chevron ↔** accolé au libellé : c'est ce qui dit que la pastille est cliquable. Sans lui, le basculement n'existait qu'au survol | |
| 26.2.2 | Survoler la pastille | Infobulle : bascule plat / 360°, l'image n'est pas compressée et part en immersif dans le casque | |
| 26.2.3 | Cliquer | Le libellé passe à « Image 360° » et la pastille change de fond (teinte de marque) | |
| 26.2.4 | Enregistrer, recharger | Le type a tenu, et la ressource n'a **pas** été recompressée | |
| 26.2.5 | Même geste sur une vidéo | Bascule Vidéo ↔ Vidéo 360° | |
| 26.2.6 | Sur un PDF, un JSON, un audio | **Aucun** chevron, pastille non cliquable — rien à basculer | |
### 26.3 — Nommage de la section
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 26.3.1 | Créer une section, lire la liste des types | **« Scène 3D »** — plus « Maquette 3D », qui excluait l'épée ou la pièce de collection | |
| 26.3.2 | Lire sa description | Elle annonce **objet ou décor**, les points d'intérêt et le casque VR | |
| 26.3.3 | Passer le manager en **EN** | « 3D scene » pour la section, « 3D model » pour la ressource — deux libellés **distincts**. Ils étaient identiques, on ne savait pas lequel on choisissait | |
| 26.3.4 | Passer en **NL** | « 3D-scène » / « 3D-model », même distinction | |
| 26.3.5 | Ouvrir la section, lire le sélecteur de mode | Titre « Ce que le visiteur fait de la scène », puis **Objet** / **Décor**, chacun avec sa phrase d'exemple (une épée / une salle reconstituée) | |
| 26.3.6 | Basculer d'un mode à l'autre, enregistrer, recharger | Le mode a tenu. Il ne change rien à l'écran du manager : il ne se voit que dans le casque | |
| 26.3.7 | Section sans modèle choisi | « Choisissez d'abord un modèle 3D dans la médiathèque », et pas une zone morte | |
---
## 27. Hors ligne complet et fond immersif (XR-4, E4 et E5)
Écrit le 2026-09-12, **jamais exécuté**. Deux chantiers indépendants, regroupés parce qu'ils se
testent au même moment — le second n'a de sens que si le premier a rempli le cache.
### 27.1 — Préchargement des médias
Le cache existait mais ne se remplissait **qu'à la demande** : une borne branchée sans réseau
avait ses titres et pas une image. C'est ce que ce bloc vérifie, et c'est l'argument qui a fait
choisir Unity contre WebXR.
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.1.1 | Casque neuf (`forgetPairing`), appairer, laisser l'app **cinq minutes** sur le menu sans rien toucher | Dans `adb logcat` : `[Preload] N médias à mettre en cache`, puis `[Preload] Terminé — N/N disponibles hors ligne`, avec le poids du cache en Mo | |
| 27.1.2 | Relancer l'app **sans rien effacer** | `N déjà en cache`, **0 téléchargement** : le préchargement ne retélécharge jamais ce qu'il a | |
| 27.1.3 | **Couper le wifi du casque**, relancer | Le menu s'affiche, et **chaque section s'ouvre avec ses images** — c'est le seul test qui compte | |
| 27.1.4 | Toujours hors ligne : ouvrir une 360, une vidéo 360, une scène 3D | Les trois se chargent depuis le disque | |
| 27.1.5 | Toujours hors ligne : viser un point d'intérêt qui porte un audio | **Le son sort.** Il était streamé depuis l'URL : le hotspot était muet dès que le réseau tombait, alors que le fichier pouvait être sur le casque | |
| 27.1.6 | Une configuration contenant une section Video **YouTube** | Elle n'est **pas** téléchargée (lien externe), et le journal ne compte pas d'échec pour elle | |
| 27.1.7 | Une configuration avec un PDF et un JSON | Pas téléchargés non plus : le casque ne les affiche pas, les prendre ne ferait que remplir le disque | |
| 27.1.8 | Couper le wifi **pendant** le préchargement, le remettre, relancer l'app | Les médias déjà pris sont gardés, les autres repartent. Aucun message au visiteur | |
| 27.1.9 | ⚠️ **Mesurer** : durée du préchargement complet et poids final du cache, sur une vraie configuration | C'est le chiffre qui dira si une borne peut être mise en service le matin même ou la veille | |
| 27.1.10 | Vérifier l'espace disque du casque après plusieurs configurations | Rien ne purge aujourd'hui. Si ça monte, c'est un sujet — pas un bug | |
### 27.2 — Fond immersif, côté manager
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.2.1 | Instance **sans** l'add-on immersif → ouvrir une configuration | **Aucune** carte « Fond immersif » : pas de réglage qu'on ne peut ni remplir ni voir | |
| 27.2.2 | Activer l'add-on (écran SuperAdmin), rouvrir | La carte apparaît | |
| 27.2.3 | Choisir une **image 360** comme fond | Sous le champ : « Panorama 360° ». Le type n'est **jamais demandé** — il est déduit du média, donc impossible à renseigner faux | |
| 27.2.4 | Choisir une **vidéo 360** à la place | Le libellé devient « Vidéo 360° », avec l'avertissement sur le décodeur d'une borne | |
| 27.2.5 | Choisir un **modèle 3D** | « Décor 3D : prévu par le contrat, pas encore affiché en fond » — annoncé avant, pas découvert dans le casque | |
| 27.2.6 | Ne **pas** renseigner l'image de repli, enregistrer | Autorisé, mais la phrase d'aide dit ce que ça coûte : web, mobile et tablettes n'afficheront aucun fond | |
| 27.2.7 | Renseigner le repli, enregistrer, recharger | Les deux ont tenu | |
| 27.2.8 | « Retirer le fond », enregistrer, recharger | Le fond est parti — et **pas** un fond vide avec un type resté collé | |
| 27.2.9 | Onglet XR → sous-onglet Configuration, en bas | La même carte, pour le **fond du menu d'accueil** cette fois, enregistré immédiatement | |
| 27.2.10 | Le même écran pour le mobile, le web, le kiosk | **Aucune** carte de fond : ces canaux n'ont pas de menu à décorer | |
| 27.2.11 | Passer en EN et NL | Les dix libellés traduits | |
### 27.3 — Fond immersif, côté casque
| # | Action | Résultat attendu | ✓ |
|---|--------|-----------------|---|
| 27.3.1 | Configuration **sans** fond, lancer l'app | Menu sur fond noir, comme avant. Aucun message, aucune erreur | |
| 27.3.2 | Configuration avec un **panorama**, relancer | Le menu flotte **dans le lieu**, pas dans le noir. C'est tout l'objet du chantier | |
| 27.3.3 | Ouvrir une section 360 depuis ce menu | La 360 remplace le fond | |
| 27.3.4 | La fermer | **Le fond revient.** `SkyboxView` restaure le ciel précédent, et le ciel précédent, c'est le fond | |
| 27.3.5 | Fond en **vidéo 360** | Elle tourne en boucle et **sans son** — un fond qui parle par-dessus un commentaire serait intenable | |
| 27.3.6 | ⚠️ Laisser une borne à fond vidéo tourner **une heure**, relever la batterie | Le décodeur tourne en permanence. À mesurer avant de vendre ce cas comme normal | |
| 27.3.7 | Assigner au casque une configuration **avec un autre fond**, attendre le battement | Le nouveau fond remplace l'ancien, sans redémarrage et sans que les deux vidéos tournent | |
| 27.3.8 | Fond de type **Scène 3D** | Ciel par défaut + une ligne dans le journal. Pas d'écran noir muet | |
| 27.3.9 | ⚠️ Fond dont la ressource a été **supprimée** de la médiathèque | Le menu reste utilisable sur fond noir. Jamais un plantage au démarrage | |
| 27.3.10 | Reposer le casque, le reprendre (nouvelle visite) | Le fond est toujours là : il appartient à la visite, pas à la session | |
---
## Hors scope (non implémenté)
| Feature | Statut |
|---------|--------|
| Ressource 360° / panorama | ❌ — 📦 **V2** (2026-08-07) |
| AR image tracking (Mind AR) | ❌ — 📦 **V2** (2026-08-07) |
| SectionForm (formulaires personnalisés) | ❌ — 📦 **V2** (2026-08-07) |
| Audit log API Keys | ❌ |

View File

@ -508,11 +508,13 @@
### SectionForm — Formulaires personnalisés — 📦 **V2**
> **Reporté en V2 le 2026-08-07.** Nouveau type de section + table de réponses : purement additif, aucun impact sur le schéma existant, donc rien n'oblige à le faire passer avec la migration Postgres.
>
> → **Plan technique complet, ancré dans le code (2026-09-15) : [v2/section-form-plan.md](v2/section-form-plan.md)** — modèle de données, fichiers à toucher par repo, décisions arrêtées. **Ne pas refaire l'analyse.**
- [ ] ❌ **Nouveau type de section : formulaire configurable**
- **Backend** : nouveau modèle `SectionForm` avec `List<FormField>` (types : `text`, `rating`, `single_choice`, `multiple_choice`) + `FormResponse` pour stocker les réponses (visiteur anonyme, timestamp, configurationId)
- **Manager-app** : constructeur de formulaire (ajout/réordonnancement de champs, labels multilingues, choix pour les champs à options) + page de résultats avec agrégats (moyenne pour les ratings, répartition pour les choix, liste pour les textes libres)
- **Visitapp + Tablet-app** : `form_page.dart` affichage des champs selon le type, soumission via API, confirmation de réception (pas de compte requis, réponse anonyme)
- **Backend** : nouveau modèle `SectionForm` avec `List<FormField>` (types : `text`, `long_text`, `rating`, `single_choice`, `multiple_choice`) + `FormSubmission` pour stocker les réponses (visiteur anonyme, timestamp, configurationId). ⚠️ Table dédiée, **pas** `VisitEvent.Metadata` — et `GetEmbeddableText` indexe les libellés de questions mais **jamais les réponses**
- **Manager-app** : constructeur de formulaire (ajout/réordonnancement de champs, labels multilingues, choix pour les champs à options) + résultats avec agrégats (moyenne pour les ratings, répartition pour les choix, liste pour les textes libres) **dans un onglet de l'écran de section**, pas dans `Screens/Statistics/`, avec export CSV
- **Visitapp + Visitapp-web + Tablet-app** : affichage des champs selon le type, soumission via API, confirmation de réception (pas de compte requis, réponse anonyme). ⚠️ Sur `tablet-app`, reset après soumission **et** sur inactivité : sur borne fixe le visiteur suivant hérite de l'écran du précédent
- **Use cases** : feedback post-visite, enquête de satisfaction, inscription atelier, sondage exposition
---

View File

@ -207,6 +207,13 @@ Meshy / Tripo / Rodin ont des API qui marchent, et le multi-vues améliore nette
ce qui sort est un objet isolé, silhouette lisible, topologie sale, texture baked, échelle et pivot
arbitraires.
> **Précisé le 2026-09-15 — tout passe par fal, y compris Meshy.** Vérifié ce jour : fal expose
> Meshy (v5, v6-preview, **v7**), Hi3D, Tripo3D, Hunyuan3D et Trellis. Aucun second fournisseur à
> intégrer. Le multi-vues a un endpoint dédié, `meshy/v7/multi-image-to-3d` (~1,20 $, 1,40 $ en
> ultra), face à ~0,020,05 $ en mono-image : **facteur ~25**, donc deux lignes de catalogue et une
> bascule annoncée à l'écran. Hi3D est excellent en fidélité structurelle mais **mono-image
> seulement**. Détail du flux de prise de vue guidé : `studio-plan.md`, lot 11 et décision 24.
⚠️ **Pour un objet de collection, le problème n'est pas la qualité, c'est la véracité.** Un visiteur
qui fait tourner « le casque de Léon » croit voir l'objet. Deux conséquences :
@ -258,12 +265,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 +346,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 +375,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 +504,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. |

177
v2/section-form-plan.md Normal file
View File

@ -0,0 +1,177 @@
# SectionForm — formulaires visiteurs
> Chantier **V2**, reporté le 2026-08-07, **analysé dans le code le 2026-09-15**.
> Spec d'origine : `todo-features.md` § SectionForm · carte kanban `cards/5-planifie/240-…`
> Ce document ne rouvre pas la décision « V2 » : il dit **comment** on le fait quand on le fait.
---
## Pourquoi ce type de section
Tout le flux produit va aujourd'hui du lieu vers le visiteur. Ce qui remonte est **dérivé** :
`VisitEvent` (télémétrie anonyme) et `VisitorQuestion` (ce qui a été demandé au guide IA).
Aucun mécanisme ne permet à un lieu de **poser une question**.
Use cases visés, dans l'ordre de valeur commerciale : enquête de satisfaction (les musées
publics en produisent pour leurs rapports de subsides), livre d'or, sondage d'exposition,
inscription à un atelier.
---
## Décisions actées
| Décision | Conséquence |
|---|---|
| **Réponses anonymes**, sans compte ni identification | Pas de champ « nom », pas de liaison à une personne. Ça allège massivement le volet RGPD — on reste sur le régime déjà décrit au §8 des CGU |
| **Les trois fronts visiteur**, kiosk compris | `mymuseum-visitapp`, `visitapp-web`, **`tablet-app`** — une borne en fin de parcours est le meilleur emplacement pour une enquête de satisfaction |
| **Table de réponses dédiée**, pas `VisitEvent.Metadata` | Rétention propre, export, suppression ciblée, requêtes par champ |
| **Les réponses ne sont jamais indexées** pour le RAG | Seuls les libellés des questions le sont |
| Pas d'upload de fichier en V1 | Les règles Firebase Storage de prod sont encore ouvertes en écriture — mauvais moment pour ajouter une porte d'upload public |
---
## Ce qui sert de socle (déjà écrit)
La moitié de la plomberie existe. Trois précédents à reprendre plutôt qu'à réinventer :
1. **`StatsController.TrackEvent`** (`Controllers/StatsController.cs`) — le **seul** endpoint
`[AllowAnonymous]` où un visiteur écrit en base. C'est exactement le contrat de soumission
d'un formulaire : modèle à copier, y compris la validation défensive.
2. **`SectionQuiz` + `QuizQuestion`** (`Data/SubSection/`) — structurellement un formulaire :
liste ordonnée de questions traduites en `jsonb`, options en `jsonb`, un `QuestionType`.
`SectionForm` c'est le même squelette **moins** la bonne réponse, **plus** le stockage.
3. **`VisitorQuestionPurgeService`** (`Services/`) — le job de purge par rétention existe et
tourne : `FormSubmission` s'y branche sans rien inventer.
Et le coût d'ajout d'un type de section est balisé : `Scene3D`, le dernier ajouté, a touché
11 fichiers backend et 8 dans `manager-app`. La partie réellement neuve ici, c'est **l'écran
de réponses**, qui n'a aucun équivalent.
---
## Modèle de données
```
DTOs/SectionType.cs
Form ← ajouté EN FIN d'enum
(persisté en int : cf. le commentaire de Scene3D)
Data/SubSection/SectionForm.cs : Section
List<FormField> Fields
List<TranslationAndResourceDTO> IntroText jsonb
List<TranslationAndResourceDTO> ConfirmationText jsonb
DateTime? ClosingDate
int RetentionDays
Data/SubSection/FormField.cs
Label jsonb traduit (même forme que QuizQuestion.Label)
Order, FieldType, IsRequired
Options jsonb (pour single_choice / multiple_choice)
Data/FormSubmission.cs
Id, InstanceId, SectionId, ConfigurationId
SessionId, Language, Answers jsonb, Timestamp
[Index(InstanceId)] [Index(SectionId)]
```
`FormSubmission` vit à la racine de `Data/`, avec `VisitEvent` et `VisitorQuestion` — c'est de
la donnée de visite, pas du contenu éditorial.
### Types de champs — V1 volontairement courte
`text` (court), `long_text`, `single_choice`, `multiple_choice`, `rating` (1-5).
Pas d'email, pas de date, pas d'upload. Les quatre premiers couvrent l'enquête de
satisfaction et le sondage, qui sont les deux vrais cas.
---
## Backend
- `DTOs/SubSection/FormDTO.cs` + branchement dans **`Services/SectionFactory.cs`**
(le `switch` de désérialisation **et** le `switch` de construction).
- `Controllers/SectionFormController.cs`, sur le modèle de `SectionScene3DController` :
- CRUD admin, authentifié ;
- `POST /api/SectionForm/submit` en **`[AllowAnonymous]`** ;
- `GET /api/SectionForm/{id}/submissions` (admin) — liste + agrégats ;
- `GET /api/SectionForm/{id}/export` — CSV ;
- `DELETE /api/SectionForm/{id}/submissions` — purge manuelle.
- `GetEmbeddableText` : **les libellés de questions oui, les réponses jamais.** Le
raisonnement est déjà écrit dans `SectionQuiz` (« le guide les réciterait au premier
visiteur qui demande ») ; ici ce serait pire — le guide recracherait les avis d'autres
visiteurs, y compris désobligeants, à un visiteur suivant.
- **L'endpoint de soumission est ouvert** : rate-limit par `SessionId`/IP (la politique
`UseRateLimiter()` existe déjà depuis le chantier API Keys), respect de `ClosingDate`,
et cap de soumissions par session.
---
## manager-app
Fichiers à toucher, identifiés sur le précédent `Scene3D` :
| Fichier | Nature |
|---|---|
| `lib/client.dart` + `manager_api_new/` | client généré, **édité à la main** |
| `lib/Screens/Configurations/new_section_popup.dart` | entrée « Formulaire » |
| `lib/Components/fetch_section_icon.dart` | icône du type |
| `lib/Screens/Configurations/Section/section_detail_screen.dart` | routage vers l'éditeur |
| `lib/Screens/Configurations/Section/SubSection/Form/form_config.dart` | **neuf** — constructeur de formulaire |
| `lib/Screens/Configurations/Section/SubSection/Form/form_submissions.dart` | **neuf** — onglet réponses |
| `lib/l10n/app_{fr,en,nl}.arb` | i18n obligatoire, aucun littéral dans un widget |
**Où vivent les réponses** : dans un **onglet de l'écran de la section**, pas dans
`Screens/Statistics/`. L'admin qui ouvre son formulaire veut ses réponses sous les yeux ;
`Statistics` répond à une autre question (la fréquentation).
Contenu de l'onglet : en-tête d'agrégats (nombre de réponses, moyenne des `rating`,
répartition des choix en barres horizontales — la charte de `stats-screen-plan.md`
s'applique), puis la liste des textes libres, puis l'export CSV.
---
## Fronts visiteur
| Repo | Fichier | Note |
|---|---|---|
| `mymuseum-visitapp` | `lib/Screens/Sections/Form/form_page.dart` | à côté de `Quiz/quizz_page.dart` |
| `visitapp-web` | `src/components/sections/FormSection.tsx` | **obligatoire** — le plan Essentiel est web-only. Lire le Flutter d'abord, c'est la référence de comportement |
| `tablet-app` | `lib/Screens/Form/form_view.dart` | à côté de `Screens/Quizz/quizz_view.dart` |
### Le point d'attention propre au kiosk
Sur une borne fixe, le visiteur suivant hérite de l'écran du précédent. Même anonyme, une
saisie libre à moitié écrite qui reste affichée est un défaut visible. Il faut donc, sur
`tablet-app` uniquement : **reset du formulaire après soumission** et **reset sur
inactivité**, sur le même timer que celui qui ramène déjà la borne à l'accueil.
---
## RGPD — allégé, pas nul
L'anonymat retire le gros du sujet : pas de personne identifiée, donc pas de droit d'accès
à exercer par visiteur. Restent deux points, tous deux déjà traités ailleurs dans le produit :
- **La saisie libre peut contenir des données personnelles spontanées.** C'est exactement ce
que le §8 des CGU écrit déjà pour le champ de question du guide IA — la même clause couvre
ce cas, il suffit de l'étendre au formulaire lors de la relecture juridique déjà planifiée
(carte `195-faire-valider-les-cgu-par-un-juriste`).
- **Rétention** : `RetentionDays` paramétrable + purge automatique via le service existant.
---
## Hors périmètre V1 du chantier
Logique conditionnelle entre champs, pages multiples, notification email à chaque réponse,
upload de fichier, réponses liées à un visiteur identifié. Le risque de ce chantier est de
glisser vers « on refait Google Forms » — or la valeur ici n'est pas la richesse de
l'éditeur, c'est d'être **déclenchable dans le parcours de visite** (fin d'un parcours
guidé, beacon de sortie, borne de fin).
---
## Positionnement commercial
À réserver aux plans **Pro et supérieurs** : la collecte de retours a une valeur propre, et
le plan Essentiel est déjà le plan « vitrine ». À arbitrer avec la grille de
`myinfomate-landing`, qui reste la source de vérité des prix.

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,11 @@
> [../roadmap.md](../roadmap.md) comme référence technique — la roadmap garde le volet commercial.
>
> ⚠️ **Rien de ce document n'est V1.** La bascule Postgres et le backlog V1 passent avant.
>
> ⚠️ **Nommage — les lots s'appellent `XR-1` à `XR-5`** (renommés le 2026-09-11 ; ils s'appelaient
> `V-1``V-5`, ce qui se confondait avec « V1 », la version). **Rien à voir avec les lots du backlog
> V1**, qui sont lettrés `A` à `J` dans [../v1-plan.md](../v1-plan.md) avec des items `A0`, `C6`,
> `I3`… Deux numérotations, deux documents, deux horizons.
---
@ -29,11 +34,11 @@ de flotte — pas les fondations.
| `ApplicationInstance` VR | `Controllers/ApplicationInstanceController.cs` — CRUD complet + `application-link` | ✅ Une instance VR se crée déjà par l'API, sans une ligne de code neuve |
| `AppConfigurationLink` | `Data/AppConfigurationLink.cs` | ✅ Porte déjà `DeviceId` (nullable) — c'est exactement le mécanisme « une config par appareil » du kiosk |
| Entité `Device` | `Data/Device.cs` | ✅ `Identifier`, `Name`, `Connected`, `BatteryLevel`, `ConnectionLevel`, `AppVersion`, `LastSeen`, `InstanceId`, `ConfigurationId` |
| `DeviceController` | 7 endpoints (list, detail, create, update, mainInfos, delete) | ⚠️ Existe, mais **hardcodé sur `AppType.Tablet`** (`DeviceController.cs:155`) |
| `DeviceController` | 7 endpoints (list, detail, create, update, mainInfos, delete) | **Multi-canal depuis le 2026-09-11** — résout l'`ApplicationInstance` sur `appType`, et `Get` filtre par canal |
| Push MQTT « config changée » | `DeviceController.cs:303` → topic `player/{device.Id}` | ✅ Le mécanisme de rafraîchissement à distance est déjà là |
| Télémétrie | `Data/VisitEvent.cs:27``public AppType AppType` | ✅ Un event VR se stocke sans aucun changement de schéma |
| Export de contenu | `GET /api/configuration/{id}/export` (`ConfigurationController.cs:388`) | ✅ Configuration + sections + ressources en **un seul appel** — le contrat idéal pour un renderer tiers |
| Auth app cliente | `GET /api/instance/appKeyByPin` (`InstanceController.cs:347`) | ⚠️ Existe, mais `ApiKeyAppType` ne connaît que `{ VisitApp, TabletApp, Other }` |
| Auth app cliente | `GET /api/instance/appKeyByPin` (`InstanceController.cs:347`) | `ApiKeyAppType.VrApp` ajouté en fin d'enum le 2026-09-11 |
### 1.2 manager-app
@ -56,38 +61,73 @@ de flotte — pas les fondations.
## 2. Ce qu'il reste à faire
### Lot V-1 — Le `Device` devient multi-canal (backend) — **2-3 j**
### Lot XR-1 — Le `Device` devient multi-canal (backend) — **2-3 j** — ✅ **FAIT le 2026-09-11**
Aujourd'hui un appareil *est* une tablette : `DeviceController.Create` va chercher
`ApplicationInstances.FirstOrDefault(ai => ai.AppType == AppType.Tablet)` en dur. Un casque
enregistré par ce chemin serait rattaché à l'instance kiosk et apparaîtrait dans l'onglet Kiosk.
- [ ] `Device.AppType` (colonne `int`, **défaut `Tablet` = 1** pour que les lignes existantes ne bougent pas) + migration EF
- [ ] `DeviceDTO.appType` / `DeviceDetailDTO`, mappé dans `ToDTO` / `ToDetailDTO` / `FromDTO`
- [ ] `DeviceController.Create` : résoudre l'`ApplicationInstance` sur `newDevice.appType` au lieu de `AppType.Tablet`
- [ ] `DeviceController.GetAll` : paramètre de requête `appType` optionnel (sans lui, comportement actuel)
- [ ] `ApiKeyAppType` : ajouter `VrApp` **à la fin** de l'enum (`{ VisitApp, TabletApp, Other, VrApp }`) — l'enum est persisté en int, ne jamais insérer au milieu
- [ ] `DeviceControllerTests` : un cas « création d'un device VR » qui vérifie le rattachement à la bonne `ApplicationInstance`
> ✅ **Fait le 2026-09-11.** Migration `20260911135527_AddAppTypeToDevice`, 224 tests au vert.
> ⚠️ **Piège rencontré** : `dotnet ef` génère `defaultValue: 0` (= `Mobile`), pas `1`. Laissé tel quel,
> le backfill sortait **toutes les tablettes existantes** de l'onglet Kiosk, qui filtre désormais sur
> `AppType`. Corrigé à la main dans la migration. Et le défaut n'est **pas** déclaré dans le modèle :
> `HasDefaultValue` rendrait la colonne `ValueGeneratedOnAdd`, donc read-only après insert — piège
> déjà documenté dans `MyInfoMateDbContext` pour les colonnes de quota de l'`Instance`, et qui aurait
> fait créer tous les casques en `Tablet`.
- [x] `Device.AppType` (colonne `int`, **défaut `Tablet` = 1** pour que les lignes existantes ne bougent pas) + migration EF
- [x] `DeviceDTO.appType` / `DeviceDetailDTO`, mappé dans `ToDTO` / `ToDetailDTO` / `FromDTO`
`DeviceDetailDTO` expose au passage `appVersion` et `lastSeen`, colonnes en base jamais exposées
- [x] `DeviceController.Create` : résoudre l'`ApplicationInstance` sur `newDevice.appType` au lieu de `AppType.Tablet`
- [x] `DeviceController.GetAll` : paramètre de requête `appType` optionnel (sans lui, comportement actuel)
- [x] `ApiKeyAppType` : ajouter `VrApp` **à la fin** de l'enum (`{ VisitApp, TabletApp, Other, VrApp }`) — l'enum est persisté en int, ne jamais insérer au milieu
- [x] `DeviceControllerTests` : un cas « création d'un device VR » qui vérifie le rattachement à la bonne `ApplicationInstance`
— plus le défaut Tablet, le 404 sans `ApplicationInstance` VR, et le filtre de `Get`
- [x] `manager_api_new` : `appType` dans `device_dto.dart` / `device_detail_dto.dart`, `number3` (VrApp)
dans `api_key_app_type.dart`**édités à la main**, la génération ne se relance pas sur ce repo
⚠️ Le champ `Device.ConfigurationId` est marqué `[Required]` et le commentaire du code le dit
« OLD WAY → AppConfigurationLink ». Le casque doit passer par `AppConfigurationLink`, pas par ce
champ ; on le remplit quand même pour ne pas casser la contrainte.
### Lot V-2 — Onglet XR dans manager-app — **4-5 j**
### Lot XR-2 — Onglet XR dans manager-app — **4-5 j** — ✅ **FAIT le 2026-09-12**
C'est le lot le moins cher : l'écran kiosk est un patron directement réutilisable, la seule
différence de fond est l'`AppType` filtré.
C'est le lot le moins cher : l'écran kiosk est un patron directement réutilisable.
- [ ] `lib/Screens/Vr_devices/vr_screen.dart` — clone de `kiosk_screen.dart` : grille de casques, pincode d'appairage en tête, état connecté/déconnecté
- [ ] Réutiliser tel quel `device_element.dart`, `dropDown_configuration.dart` (assignation d'une configuration par casque), `change_device_info_modal.dart`
- [ ] Brancher `main_screen.dart:704` : remplacer `Text("TODO vr")` par `VrScreen(applicationInstanceDTO: applicationInstanceVR)`
- [ ] Carte casque : ajouter niveau de batterie + `AppVersion` + `LastSeen` (colonnes déjà en base, jamais affichées pour le kiosk)
- [ ] `manager_api_new` : ajouter `appType` à `device_dto.dart` / `device_detail_dto.dart` et `VrApp` à `api_key_app_type.dart`**à la main**, la génération OpenAPI ne se relance pas sur ce repo
- [ ] i18n FR/EN/NL/DE : titre d'onglet, « casques », « aucun casque appairé », libellés batterie/version
> ⚠️ **Deux affirmations de ce lot étaient fausses, vérifiées dans le code le 2026-09-12 :**
>
> 1. **« la seule différence de fond est l'`AppType` filtré » — non.** La grille kiosk ne liste
> pas des `Device` : `kiosk_screen.dart:57` appelle
> `applicationInstanceGetAllApplicationLinkFromApplicationInstance`, donc des
> **`AppConfigurationLink`**. Le filtre par canal est déjà implicite (l'`ApplicationInstance` VR
> ne porte que des liens VR) et le paramètre `appType` ajouté à `/api/device` par XR-1 **ne sert
> pas** à cet écran.
> 2. **Batterie / version / dernier vu ne sont pas dans `DeviceDTO`**, seulement dans
> `DeviceDetailDTO` — donc un `GET /api/device/{id}/detail` par casque. Assumé : une flotte de
> bornes se compte en unités. À revoir si la flotte grossit (exposer les 3 champs dans `ToDTO`).
>
> Et `device_api.dart` n'exposait pas `appType` : XR-1 n'avait mis à jour que les DTO et l'enum,
> pas l'API. Ajouté à la main.
- [x] `lib/Screens/Vr_devices/vr_screen.dart` — coquille à **deux sous-onglets** (patron `_tabButton`
de `guide_ia_screen.dart`, pas un `TabBar` Material) : « Configuration » et « Casques »
- [x] Onglet Configuration : `AppConfigurationLinkScreen` réutilisé **tel quel** — il est déjà
générique sur l'`appType`, c'est ce que font Mobile et Web. Zéro code neuf
- [x] Onglet Casques : `vr_devices_tab.dart` — pincode d'appairage en tête, grille, état
connecté/déconnecté, carte « aucun casque appairé » pour un lien sans appareil
- [x] Réutiliser tel quel `change_device_info_modal.dart` et `dropDown_configuration.dart`
`device_element.dart` **non** : sa carte est un `Stack` plein cadre où les trois lignes
d'info du casque n'entrent pas. Carte dédiée, modal partagé (c'est lui, le gros morceau)
- [x] Brancher `main_screen.dart:740` — le placeholder n'était plus `Text("TODO vr")` mais
`Text(vrAppNotConfigured)`, affiché même quand l'`ApplicationInstance` VR existait
- [x] Carte casque : batterie + `appVersion` + `lastSeen`
- [x] `manager_api_new` : `appType` sur `deviceGet` / `deviceGetWithHttpInfo`**à la main**,
la génération OpenAPI ne se relance pas sur ce repo
- [x] i18n FR/EN/NL — **DE n'existe pas** dans `manager-app/lib/l10n/` (3 langues, pas 4)
Rien à faire pour le sous-menu ni pour les stats : les deux sont déjà branchés sur `isVR` / `AppType.VR`.
### Lot V-3 — Contrat de contenu pour un client non-Flutter — **1-2 j**
### Lot XR-3 — Contrat de contenu pour un client non-Flutter — **1-2 j**
Unity ne peut pas consommer le client Dart généré. Deux options :
@ -96,26 +136,61 @@ Unity ne peut pas consommer le client Dart généré. Deux options :
| Unity appelle les endpoints REST un par un, DTO C# écrits à la main | Élevé et récurrent — chaque évolution de section se répercute | ❌ |
| Unity appelle `GET /api/configuration/{id}/export` (un appel, tout le contenu) et désérialise `ExportConfigurationDTO` | Faible — le DTO d'export est déjà stable, c'est celui de l'import/export back-office | ✅ **retenu** |
- [ ] Vérifier que `Export` répond bien à une clé d'API d'app (aujourd'hui utilisé depuis le back-office authentifié)
- [ ] Documenter le JSON d'export comme contrat public versionné (c'est aussi ce qui servira au mode offline du casque)
- [ ] Décider si l'export multilingue (aujourd'hui `?language=`) doit renvoyer toutes les langues d'un coup pour la VR — un casque en borne change de langue à chaud
- [x] ~~Vérifier que `Export` répond bien à une clé d'API d'app (aujourd'hui utilisé depuis le back-office authentifié)~~
**Déjà fait, le plan était en retard sur le code** (vérifié le 2026-09-11) : commit `9cc45c5`
« Ouvrir l'export de configuration aux apps visiteur ». `ConfigurationController.cs:396` est
`[AllowAnonymous]` avec authentification `X-Api-Key` explicite et contrôle que la clé donne
accès à **cette** instance (403 sinon). Le commentaire du code explique pourquoi
`[Authorize(AppReadAccess)]` ne suffisait pas : ASP.NET combine les `[Authorize]` de la classe
et de l'action, et le contrôleur exige `ContentEditor`
- [x] ~~Décider si l'export multilingue (aujourd'hui `?language=`) doit renvoyer toutes les langues d'un coup pour la VR~~**tranché le 2026-09-12, et la réponse était déjà dans le code** : `language` ne filtre que les **ressources** (les audios d'une langue), les textes partant toujours dans toutes leurs traductions. **Omettre le paramètre rend donc tout**`GetReferencedResourceIds(null)` renvoie les médias de toutes les langues. Le casque l'omet désormais : changer de langue à chaud ne demande **aucun appel**, et le cache disque en garde une copie au lieu d'une par langue
- [x] 🔴 **Trou trouvé en vérifiant, corrigé le 2026-09-12** : l'export ne portait **aucun champ spécifique de section**. `Section.ToDTO()` n'est pas virtuelle, et l'export l'appelait sur une variable de type `Section` — donc une galerie sans ses images, une carte sans ses points, une maquette sans son modèle. Corrigé par `SectionFactory.ToDTO` + le pré-chargement des collections, et verrouillé par six tests. ⚠️ **Ça ne concernait pas que la VR : l'export est ce qui part en visite hors ligne**
- [x] **Contrat d'export versionné — fait le 2026-09-12.** `exportVersion: 1` et `generatedAt` dans le JSON, et les règles de compatibilité écrites sur le DTO lui-même : on **ajoute** des champs, on n'en retire ni n'en renomme ; les valeurs d'enum vont **en fin** ; un champ absent vaut « pas de valeur ». Trois consommateurs en dépendent — la visite hors ligne, l'import/export du back-office, et l'app Unity qui lit ce JSON **sans client généré**. Un export sans numéro est antérieur au 12/09 et se lit comme une version 1
### Lot V-4 — L'app Unity + Meta XR SDK — **6-10 semaines** (POC utile : 3-4 semaines)
### Lot XR-4 — L'app Unity + Meta XR SDK — **6-10 semaines** (POC utile : 3-4 semaines)
- [ ] **Socle** : Unity 6 LTS, URP, XR Plugin Management → OpenXR + *Meta XR feature group*, Meta XR Core SDK + Interaction SDK (raycast contrôleur **et** hand tracking)
- [ ] **Couche réseau C#** : appairage par pincode (clone du flux `tablet-app/lib/Screens/Configuration/config_view.dart:274`), récupération de la clé d'API, `POST /api/device` avec `appType = VR` et l'identifiant matériel du casque
- [ ] **Cache disque + offline-first** : une borne d'accueil perd le wifi ; l'export JSON et les médias se mettent en cache, l'app démarre sur le cache et se rafraîchit en tâche de fond
- [ ] **Renderer par type de section** — arbitrage tranché, voir §8
- [ ] **Lecture 360°** : dépend du lot « Ressource 360° » de todo-features.md — sans un type de ressource 360°, il n'y a rien à afficher en immersif
- [ ] **Mode borne** : verrouiller le casque sur l'app (Meta Horizon Managed Solutions ou un MDM tiers), relance auto au réveil, timeout de session, retour au menu à la repose du casque
- [ ] **Télémétrie** : `POST` de `VisitEvent` avec `AppType.VR` → les stats manager-app fonctionnent sans une ligne de plus
- [ ] **Distribution** : Meta Horizon Store (review) ou canal privé / sideload via MDM
> **Découpé en items le 2026-09-11.** Le lot n'avait que des puces, donc rien d'assignable ni de chiffrable.
>
> ✅ **Le verrou est levé depuis le 2026-09-11.** Unity 6000.0.83f1 est installé, le projet vit dans
> [`vr-app/unity/MyInfoMateVR/`](../../vr-app/), et un APK signé tourne sur un Quest 2 en Horizon OS
> v207. **E0 est fait**, E1 l'est aussi à l'exception de l'Interaction SDK — ce qui suit n'est donc
> plus de l'estimation sur du code non compilé.
>
> La **piste Scène 3D** (manifeste de scène, `SectionScene3D`, éditeur web), qui alimente E7, a son
> propre plan et sa propre numérotation **S0-S8** :
> [`vr-app/docs/04-phase3-plan.md`](../../vr-app/docs/04-phase3-plan.md). Ne pas confondre ses S<n>
> avec les E<n> ci-dessous.
>
> ⚠️ **Les scripts Unity, eux, parlaient encore `E1`/`E2` en voulant dire `S1`/`S2`** — la doc avait
> été renommée le 11/09, le code non. Corrigé le 2026-09-12 : `S1Bootstrap` / `S2Bootstrap`
> (piste Scène 3D) et `PairingBootstrap` (E2-E3, canal VR).
### Lot V-5 — Supervision de la flotte — **1-2 semaines**
| # | Item | Dépend de | Note |
|---|---|---|---|
| **E0** | ✅ **FAIT le 2026-09-11.** Unity 6000.0.83f1, projet URP `MyInfoMateVR`, APK sideloadé et lancé sur Quest 2 (Horizon OS v207) | — | Premier build IL2CPP : **21 min**, les suivants 2-3. Pièges rencontrés et leurs corrections : [`vr-app/unity-overlay/README.md`](../../vr-app/unity-overlay/README.md) |
| **E1** | 🟡 **Socle** : URP ✅, XR Plugin Management → OpenXR + *Meta XR feature group* ✅, Meta XR Core SDK 205 ✅ — **reste l'Interaction SDK**. ⚠️ **Ce reste n'est pas du code** : le raycast manette et le pincement de la main sont écrits et compilés (`Menu/AimSelector.cs`, via `OVRInput` et `OVRHand` du *Core* SDK, pas de l'Interaction SDK). Ce qui manque est un geste dans l'éditeur — importer le package et poser le building block *Hand Tracking* dans la scène ; sans lui le code retombe proprement sur la visée à la tête | E0 | ⚠️ Les API Meta bougent : prévoir des allers-retours. ~~**E5 en dépend**~~**faux, levé le 2026-09-12** : le menu se pilote au regard, sans aucun SDK d'interaction. E1 n'est plus sur le chemin critique du POC |
| **E2** | 🟡 **Couche réseau C# — écrite le 2026-09-12, jamais essayée contre un vrai serveur.** `Net/ApiClient.cs` (le seul endroit qui parle au backend, `X-Api-Key` en en-tête, erreurs rendues en phrases lisibles), `Net/PairingService.cs` (pincode → `GET /api/instance/app-key?appType=VrApp``POST /api/device` avec `appType = 3`), appairage persistant en `PlayerPrefs`. Compile contre les DLL Unity + Newtonsoft | E0, XR-1 | Le flux est celui de `tablet-app/.../config_view.dart:274` + `_fetchAppKey`. ⚠️ Un **404 sur `POST /api/device`** ne veut pas dire « introuvable » : il n'y a pas d'`ApplicationInstance` VR sur l'instance |
| **E3** | 🟡 **Lecture de l'export — écrite le 2026-09-12, jamais essayée.** `Net/ConfigurationExport.cs` : un appel, désérialisation, sections racines triées, traduction par langue. `Boot/PairingBootstrap.cs` affiche le résultat dans le casque | E2, XR-3 | ⚠️ **Confirmé dans le code** : `language` traverse jusqu'à `Section.GetReferencedResourceIds(language)` — l'export ne rend **qu'une langue**, donc changer de langue à chaud = refaire l'appel. C'est la question ouverte de XR-3 |
| **E4** | 🟡 **Cache disque + offline-first — écrit le 2026-09-12, jamais essayé.** `Net/ContentCache.cs` (écriture atomique par fichier temporaire puis déplacement — une borne qu'on débranche le soir ne doit pas laisser un JSON tronqué ; médias nommés par hachage de l'URL, l'extension préservée pour le décodeur), `Net/ContentService.cs` (**cache d'abord**, rafraîchissement de fond dont l'échec est silencieux) | E3 | C'est l'argument n°1 d'Unity contre WebXR (§10) — donc l'item qui justifie le choix de stack. ✅ **Complété le 2026-09-12** : `Net/ContentPreloader.cs` télécharge toute la visite en tâche de fond dès que le menu est affiché, un média à la fois, sans jamais faire attendre le visiteur. Le cache ne se remplissait qu'à la demande — une borne branchée hors ligne avait ses titres et **pas une image**, ce qui vidait l'argument de sa substance. Les liens externes (YouTube), les PDF et les JSON sont écartés : le casque ne les rend pas. L'audio d'un hotspot passe désormais par le cache lui aussi (`HotspotInstance.cs`), il était streamé depuis l'URL |
| **E5** | 🟡 **Menu flottant — écrit le 2026-09-12, jamais essayé.** `Menu/AimSelector.cs` (**trois moyens de viser** : manette, main, tête), `Menu/MenuItemPanel.cs` (panneau + vignette + anneau de progression), `Menu/FloatingMenu.cs` (arc à 2,2 m, posé une fois et qui **ne suit pas la tête**), `Boot/MenuBootstrap.cs` (l'app : appairage → cache → menu → télémétrie) | ~~E1~~, E3 | ⚠️ **E1 n'est plus une dépendance** — voir le §8bis. ✅ **Le fond immersif est livré le 2026-09-12** : type `ImmersiveBackground` porté par la `Configuration` *et* par l'`ApplicationInstance` VR comme le prévoit le §4 de [immersif-frontiere-plan.md](immersif-frontiere-plan.md), migration `AddImmersiveBackground`, sélecteur dans manager-app (le **type est déduit** de la ressource, jamais demandé), et `Menu/ImmersiveBackdrop.cs` côté casque. Sans lui le menu flottait dans le noir. ⚠️ Le repli 2D n'est pas décoratif : web, mobile et kiosk n'affichent aucun fond sans lui. Un fond de type `Scene3D` est accepté par le contrat mais **pas encore rendu** — il retombe sur le ciel par défaut, et le dit dans le journal |
| **E6** | 🟡 **Lecture 360° — écrite le 2026-09-12, jamais essayée.** `Menu/SkyboxView.cs` : image et vidéo équirectangulaires dans le même shader `Skybox/Panoramic`, la vidéo via une `RenderTexture` 4096×2048 alimentée par un `VideoPlayer` qui lit **depuis le cache disque**. Le ciel précédent est restauré à la fermeture — c'est un réglage global, sinon le visiteur suivant hérite du décor du précédent. **Le retour ne reste pas affiché** : 4 s au début, puis il s'efface et revient quand le visiteur **baisse les yeux** ou prend une manette — un panneau planté en permanence rappellerait qu'on regarde une image, ce qu'on cherchait à faire oublier. ⚠️ **Mémoire** : `LoadImage` décode en RGBA32, soit **134 Mo** pour une 8192×4096 — compressée en ASTC au chargement et libérée à la sortie, sans quoi l'app se fait tuer au bout de quelques 360 | E5, ~~**ressource 360°**~~ ✅ | ⚠️ **`Skybox/Panoramic` doit être dans *Always Included Shaders*** : construit au runtime, il n'est référencé par aucune scène, le build l'élimine, et le ciel sort **magenta sur le casque et correct dans l'éditeur** — exactement le piège déjà payé avec glTFast |
| **E7** | 🟡 **Maquette 3D et ses POI — écrit le 2026-09-12, jamais essayé.** `SectionModel3D` côté backend (+ `GeoPoint.Model3DPosition`, migration), `Model3DConfig` dans manager-app (**le viewer web en iframe**, protocole `postMessage` déjà défini par `bridge.ts`), `Menu/Model3DView.cs` côté casque | E5, ~~`SectionModel3D`~~ ✅ | **Fondations faites le 2026-09-11** dans la piste S : chargement GLB au runtime (glTFast, 50 Mo en 1,4 s sur Quest 2), **conversion d'axes glTF→Unity prouvée** au repère de calibration, hotspots écrits. ➡️ **E7 n'a donc rien dessiné** : il a branché la source de données sur un moteur qui existait. `Model3DView` construit un `SceneManifest` depuis la section et appelle `SceneBuilder` — les deux modèles avaient été pensés l'un pour l'autre, un `SceneHotspot` porte déjà un `GeoPointId`. Voir la frontière §4bis |
| **E8** | 🟡 **Slider, Map, Parcours et Event écrits le 2026-09-12, jamais essayés**`Menu/PagedView.cs` (la vue commune : une chose à la fois, grande, devant soi, image de 1,6 m à 2,5 m, flèches, retour) et `Menu/SectionPages.cs` (ce qui distingue les trois types). **Reste la Video 360**, bloquée par la ressource 360° | E5 | Arbitrage des 13 types tranché au §8. ⚠️ **Deux écarts assumés** : la **Map** est rendue en **liste de POI**, pas en maquette — « le plan posé devant soi » demande `SectionModel3D` (§9), et une carte Google sur un panneau flottant serait *moins* bonne qu'un téléphone. Le **Parcours** est rendu comme une Map, pour la même raison. L'**Event** ne montre que **la journée en cours**, et retombe sur les dates suivantes si elle est vide — c'est l'usage « écran d'attente » du §8 |
| **E9** | 🟡 **Télémétrie — écrite le 2026-09-12, jamais essayée.** `Net/Telemetry.cs`, envoi « tire et oublie » | E3 | Les stats manager-app fonctionnent **sans une ligne de plus** : le filtre est déjà générique sur `AppType.values`. ⚠️ Deux pièges vérifiés dans `StatsController` : la route est **`POST /api/stats/event`** (pas `/api/visitevent`), et `appType` part en **chaîne** parsée par nom — une faute de frappe ne lève pas d'erreur, elle fait **retomber sur Mobile** et compte les visites du casque dans le canal mobile |
| **E10** | 🟡 **Session de borne écrite le 2026-09-12, jamais essayée**`Kiosk/KioskSession.cs` : fin de visite à la **repose du casque** (le signal le plus fiable) ou après **90 s sans rien**, retour au menu **recentré devant le visiteur suivant**, nouvelle session de statistiques, écran qui ne s'endort pas. ⚠️ Sans SDK Meta : la présence se lit par `CommonUsages.userPresence` d'Unity XR, donc **rien à configurer dans la scène**. **Reste** le verrouillage de l'app sur le casque, qui n'est pas du code — c'est le mode appareil de Meta | E5 | MDM inutile à 1-3 casques (risque n°3 rétrogradé) — sideload + mode appareil suffisent. ⚠️ Le recentrage du menu n'est pas un détail : le visiteur suivant n'arrive pas orienté comme le précédent, et un menu resté dans son dos est introuvable |
| **E11** | **Distribution** : Meta Horizon Store (review) ou canal privé / sideload | E10 | ⚠️ Les programmes business de Meta bougent vite : à revérifier avant de l'écrire dans une offre |
- [ ] Client MQTT dans Unity (MQTTnet) : heartbeat, batterie, abonnement à `player/{deviceId}` — le publish côté serveur existe déjà (`DeviceController.cs:303`)
- [ ] Alternative pour le POC : polling de l'export toutes les N minutes. Moins élégant, 10× moins cher, suffisant pour 1-3 casques
- [ ] Remontée batterie/version/dernier vu (colonnes déjà présentes sur `Device`)
**Périmètre du POC (3-4 semaines)** : E0 → E5 → E6, plus E2/E3 dont ils dépendent. Soit
**menu immersif + vidéo 360**, et c'est exactement la démo. E7 et E8 sont la couverture, pas le POC.
### Lot XR-5 — Supervision de la flotte — 🟡 **l'essentiel écrit le 2026-09-12**
- [x] **Battement HTTP plutôt que MQTT**`PUT /api/device/{id}/heartbeat` côté serveur (batterie, version ; le serveur pose lui-même `Connected` et `LastSeen`, puisque recevoir le battement *est* la preuve), `Kiosk/FleetReporter.cs` côté casque, toutes les 3 min et une fois au démarrage. À 1-3 casques, MQTT coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour **pousser** vers le casque (recharger une config à distance), pas pour remonter
- [x] **Remontée batterie / version / dernier vu** — c'est ce qui remplit l'onglet XR livré en XR-2, qui affichait « — » faute d'alimentation. ⚠️ `Update` n'écrivait **ni `AppVersion` ni `LastSeen`** : ces deux colonnes n'avaient aucun chemin d'écriture
- [x] 🔴 **Bug de production trouvé au passage** : `POST /api/device` répondait **403 à toute tablette** depuis le **13/03/2026** (`a452f4a`, « need to be tested ») — la policy `InstanceAdmin` de la classe contre une clé d'API qui ne porte que `AppRead`/`Viewer`. Une tablette déjà appairée ne rappelle jamais cette route, donc seul un **nouvel** appareil échouait. Corrigé, carte kanban dédiée
- [x] **Pousser une configuration vers le casque — fait le 2026-09-12, sans MQTT.** La réponse au battement porte déjà la configuration assignée à l'appareil : le casque la compare, et si elle a changé il recharge et revient au menu, sans redémarrage. **Coût : la latence du battement**, trois minutes au pire. **Ce que ça évite** : une connexion permanente à tenir sur une borne, une bibliothèque MQTT dans le build IL2CPP, et un client de plus à reconnecter quand le wifi du musée tombe
- [ ] Client MQTT (MQTTnet) et abonnement à `player/{deviceId}`**le publish serveur existe déjà** (`DeviceController.cs:413`). N'a de sens que le jour où trois minutes seront trop : tout est prêt côté serveur
- [x] **Alerte « casque muet » — faite le 2026-09-12.** Bandeau rouge sur la carte du casque après **une heure** sans battement, soit vingt battements manqués. ⚠️ Elle corrige un mensonge : la pastille « connecté » vient du dernier battement reçu, elle ne sait pas qu'il date d'hier
---
@ -123,13 +198,13 @@ Unity ne peut pas consommer le client Dart généré. Deux options :
| Lot | Charge | Dépendances |
|---|---|---|
| V-1 — Device multi-canal (backend) | 2-3 j | — |
| V-2 — Onglet XR manager-app | 4-5 j | V-1 |
| V-3 — Contrat de contenu | 1-2 j | — |
| **Sous-total « back-office XR prêt »** | **8-12 j** | |
| V-4 — App Unity (POC : menu + vidéo 360 + une section) | 3-4 sem. | V-1, V-3, Ressource 360° |
| V-4 — App Unity (couverture complète) | 6-10 sem. | idem |
| V-5 — Supervision flotte | 1-2 sem. | V-4 |
| ~~XR-1 — Device multi-canal (backend)~~**fait 11/09** | ~~2-3 j~~ | — |
| ~~XR-2 — Onglet XR manager-app~~**fait 12/09** | ~~4-5 j~~ | XR-1 |
| XR-3 — Contrat de contenu | 1-2 j | — |
| **Sous-total « back-office XR prêt »** | **quasi clos** — reste les 2 puces de XR-3 | |
| XR-4 — App Unity (POC : menu + vidéo 360 + une section) | 3-4 sem. | XR-1, XR-3, Ressource 360° |
| XR-4 — App Unity (couverture complète) | 6-10 sem. | idem |
| XR-5 — Supervision flotte | 1-2 sem. | XR-4 |
| **Total avec app complète** | **~3-4 mois** d'un dev | |
Le découpage est volontaire : **les 8-12 jours du back-office XR se tiennent debout tout seuls**.
@ -161,12 +236,18 @@ avant d'engager le vrai coût, qui est l'app Unity.
## 5. Recommandation de séquencement
1. **V-1 + V-2 + V-3** (~2 semaines) — peu cher, sans risque, rend le canal administrable, ferme le `TODO vr`.
2. **Ressource 360°** (déjà en V2, le plus petit des trois chantiers médias) — prérequis dur du POC.
1. ~~**XR-1 + XR-2 + XR-3** (~2 semaines)~~ — peu cher, sans risque, rend le canal administrable, ferme le `TODO vr`.
**XR-1 fait le 11/09**, **XR-2 fait le 12/09**, **XR-3 était déjà fait** (l'export est ouvert
aux apps depuis `9cc45c5`).
➡️ **Le canal VR est administrable.** Ne restent que les deux puces documentaires de XR-3
(contrat d'export versionné, décision multilingue).
2. ~~**Ressource 360°**~~**fait le 2026-09-12**`ResourceType.Image360` (11), `Video360` (12) et `Model3D` (13) **en fin d'enum**, sans migration (la colonne est déjà `integer`), verrouillées par un test valeur par valeur. Côté manager : la pastille de type du sélecteur de médias devient **cliquable** pour déclarer une 360 — aucune extension ne le dit, un panorama est un `.jpg` comme un autre — le `.glb` est accepté et déduit tout seul, et i18n FR/EN/NL.
⚠️ **Le vrai piège n'était pas l'enum, c'était 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.
**Et le prérequis bucket ne s'applique pas** : `manager-app` pousse directement dans Firebase Storage puis enregistre l'URL, donc la 360 n'attend pas le chantier « le backend ne sait pas écrire dans le bucket ». L'ancienne route serveur `/upload` plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.
3. **Décision go/no-go** conditionnée à **un client pilote avec du contenu 360° existant**.
Sans lui, ne pas démarrer V-4.
Sans lui, ne pas démarrer XR-4.
4. **POC Unity** (3-4 semaines) — menu immersif + vidéo 360 + une section riche.
5. V-4 complet et V-5 seulement après validation du POC en conditions réelles.
5. XR-4 complet et XR-5 seulement après validation du POC en conditions réelles.
---
@ -242,9 +323,10 @@ valeurs du plan au lieu de les lire à travers : `StorageQuotaBytes`, `AiTokensP
Et `IsAssistant` (`Data/Instance.cs:37`) est déjà exactement ce patron : **un add-on = un `bool`
sur l'`Instance`**.
- [ ] `Instance.HasImmersiveContent` (bool) + migration
- [ ] Relèvement de `StorageQuotaBytes` à l'activation
- [ ] Exposition dans `InstanceDTO` + l'écran SuperAdmin
- [x] `Instance.HasImmersiveContent` (bool) + migration — **fait le 2026-09-12** (`Data/Instance.cs:52`, migration `AddScene3DSectionAndImmersiveAddon`)
- [x] Exposition dans `InstanceDTO` + endpoint de mise à jour — **fait le 2026-09-12** (`InstanceController.cs:254`, 3 tests dans `InstanceAddonTests`)
- [ ] **L'écran SuperAdmin** — seul reste du lot. Aujourd'hui on active l'add-on **en base à la main** ; l'API sait le faire, personne ne peut le cliquer
- [ ] Relèvement de `StorageQuotaBytes` à l'activation — **pas fait**, et c'est le point de marge du §« stockage » ci-dessus : activer l'add-on ne relève rien pour l'instant
➡️ **Pas de nouvelle ligne de plan, pas de table de jointure, pas de refonte de la facturation.**
@ -296,6 +378,33 @@ Quatre types, et c'est exactement la démo.
---
## 8bis. Comment le visiteur désigne quelque chose — tranché le 2026-09-12
⚠️ **« Au regard » ne veut pas dire eye tracking.** Le **Quest 2 n'a pas de suivi oculaire** — seul
le Quest Pro en a, et le Quest 3 ne l'a pas non plus. Ce que suit `AimSelector`, c'est la
**direction de la tête** (head gaze) : le visiteur vise avec son nez. C'est la seule visée qui
marche sur tous les casques, donc le repli garanti — et elle ne demande **aucun SDK**, juste la
transformation de la caméra.
Les trois modes coexistent, par ordre de priorité :
| Mode | Déclenchement | D'où vient l'API | Pourquoi il est là |
|---|---|---|---|
| **Manette** | Gâchette d'index | `OVRInput` — Meta XR **Core** SDK, déjà installé | Le plus rapide et le plus précis. Le mode de la démo et de l'installation surveillée |
| **Main** | Pincement pouce-index | `OVRHand.PointerPose`**Core** SDK aussi | Le plus naturel. Demande le building block *Hand Tracking* dans la scène ; absent, on retombe sans erreur |
| **Tête** | **Temporisation de 1,2 s** | Aucune — `Camera.main` | Le repli universel. **C'est le mode d'une borne publique** : le visiteur met le casque et ça marche, sans rien dans les mains, sans rien à distribuer ni à recharger |
**La temporisation ne s'applique qu'à la tête.** La manette et la main ont un geste explicite ;
imposer 1,2 s à quelqu'un qui vient d'appuyer serait absurde. À la tête il n'y a rien pour valider,
et sans attente **tout ce que le visiteur regarde se déclenche, y compris ce qu'il ne fait que
lire** — c'est le « Midas touch », et l'anneau qui se remplit est ce qui rend l'attente supportable.
➡️ **Conséquence sur le planning** : la dépendance `E5 → E1` du tableau était fausse. L'Interaction
SDK apporte le confort (rayons stylés, poignées, poke), pas la capacité de sélectionner. **E1 sort
du chemin critique du POC.**
---
## 9. POI sur modèle GLB — le modèle de données existe déjà à 80 %
**La plus grosse économie du chantier, repérée le 2026-08-31.**
@ -306,10 +415,13 @@ est titre + description + **`Resource`**, donc **audio**. Le tout multilingue vi
➡️ Un POI sur GLB, c'est **le même objet avec une position locale (x,y,z) au lieu d'une lat/lon**.
- [ ] `GeoPoint.Model3DPosition` nullable (x,y,z + rotation) à côté du `Geometry` PostGIS existant
- [ ] `SectionModel3D` référençant une ressource GLB
- [ ] `ResourceType.Model3D`**en fin d'enum**, il est persisté en int
- [ ] Éditeur de placement de POI en 3D dans manager-app — **le seul morceau non trivial**
- [x] `GeoPoint.Model3DPosition` nullable (x,y,z + rotation Y) en jsonb, à côté du `Geometry` PostGIS existant
- [x] **`SectionScene3D`** référençant une ressource GLB — **un vrai type de section, pas une Map augmentée**. Une scène n'est pas une carte : elle n'a ni fond cartographique, ni zoom, ni fournisseur, ni coordonnées terrestres. Ce qu'on réutilise, c'est le **`GeoPoint`** — il portait déjà `SectionMapId` *et* `SectionEventId`, un troisième rattachement suit le patron
- [x] ⚠️ **Nommé `Scene3D` et pas `Model3D`, décidé le 2026-09-12.** Le discriminateur TPH est une **chaîne persistée** : renommer tant que rien n'est en production coûte zéro, renommer après la première maquette livrée coûte une migration de données. Les objets posés, les personnages et la navigation du §S4 viendront ici en colonnes nullables — **pas dans un second type à faire cohabiter**
- [x] **Champ `Mode` : `Asset` ou `Scene`** — l'opposition du §4bis, désormais dans la donnée. **Asset** : un objet posé devant le visiteur, qu'il manipule, les points tournent avec lui. **Scene** : un décor dans lequel il est, les points restent fixes. Même GLB, même modèle de point, même éditeur : ce qui change, c'est **où l'on met le visiteur**, et aucun fichier ne peut le deviner. Côté casque, le mode décide si le rig est passé au `SceneBuilder` (sans quoi le visiteur se retrouverait *dans* l'épée) et où le modèle est posé — 1,20 m de haut, 1,50 m devant, en mode objet
- [x] `ResourceType.Model3D` — en fin d'enum (fait avec la ressource 360°)
- [x] ~~Éditeur de placement de POI en 3D dans manager-app — **le seul morceau non trivial**~~**il n'était pas à écrire**. `vr-app/viewer` sait déjà charger un GLB et déplacer des objets à la souris (étape S3), et **son protocole `postMessage` existait déjà** (`bridge.ts`), pensé pour ce cas : il parle en manifeste de scène, le même objet que lit le casque. `Model3DConfig` le monte en **iframe**`manager-app` le fait déjà trois fois (`policy_screen`, `web_view`, `pdf_web_viewer`) — lui envoie un manifeste et relit les positions
- [ ] ⚠️ **Le viewer n'a jamais tourné** (S3 écrit, jamais essayé) et doit être **servi sous le même domaine** que le manager, ou passé par `--dart-define=VIEWER_URL=…` en développement
Le back-office réutilise l'éditeur de POI existant ; le multilingue et l'audio suivent gratuitement.
@ -359,11 +471,7 @@ là, le web redevient le bon choix.
par code (plutôt que du YAML `.unity` écrit à la main, illisible et cassant), `manifest.json` avec
les packages Meta XR, menu flottant, swap de skybox 360, chargement GLB, raycast sur POI avec audio.
**Ne peut pas** : ⚠️ **Unity n'est pas installé sur cette machine** — vérifié le 2026-08-31, ni
`Program Files/Unity`, ni Unity Hub. Donc pas de compilation, pas d'APK, pas de rendu, pas de
casque de test. Le livrable est un projet *plausible* : il faut l'ouvrir et itérer sur les erreurs
de compilation. **Compter 2 à 4 allers-retours**, surtout sur le SDK Meta dont les API bougent
entre versions.
**Ne peut plus servir d'excuse** : ✅ **Unity est installé depuis le 2026-09-11** (6000.0.83f1 + Hub), avec le module Android, un Quest 2 en mode développeur et un APK qui tourne. Compilation, build et rendu sur casque sont vérifiables. Ce paragraphe disait l'inverse jusqu'au 2026-09-11, sur une vérification du 2026-08-31. **Reste vrai** : les API du SDK Meta bougent entre versions. Les allers-retours annoncés ont bien eu lieu — résolution de packages, shaders éliminés au build, `targetSdkVersion` Horizon OS. Ils sont consignés dans [`vr-app/unity-overlay/README.md`](../../vr-app/unity-overlay/README.md) plutôt que redécouverts.
Pour *voir* le rendu, la maquette WebXR/three.js est le chemin court : visible sur écran ou dans le
navigateur du Quest en immersif, itérable en secondes. Précédent dans le repo : la démo villa