Studio : ordre de passage du plan de test et socle d'upload

Le plan de test gagne un § 0bis : le lot 0 a réécrit tout le chemin d'upload
(URL V4 signée puis POST /ingest) et rien n'a jamais été déposé depuis un
navigateur, alors que les § 1 à 5 en dépendent tous. Ajout aussi de l'ordre de
passage en quatre séances, de la façon de reporter un échec, et des résultats
déjà obtenus sur le § 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-16 15:18:40 +02:00
parent d879766cae
commit a588c53549
2 changed files with 181 additions and 4 deletions

View File

@ -9,20 +9,67 @@
--- ---
## Comment passer ce plan — ajouté le 2026-09-16
**Quatre séances, dans cet ordre.** Chacune est arrêtée par la précédente : inutile de chercher un bug
de narration si l'upload est cassé en dessous.
| Séance | Sections | Durée | Ce qu'un ❌ arrête |
|---|---|---|---|
| **1. Le socle** | § 0, § 0bis | ~45 min | Tout. Rien d'autre ne se teste |
| **2. L'argent et l'image** | § 1, § 2, § 2bis | ~1 h | Les § 3 à 5 (pas de génération fiable, pas de narration) |
| **3. La narration** | § 3, § 4 | ~1 h 30 | Les § 5 et 5bis côté visiteur |
| **4. Les trois fronts** | § 5, § 5bis, § 6 | ~1 h | Rien — c'est la fin |
**Comment reporter.** Un ✅ tout court suffit quand c'est conforme. Un ❌ demande trois choses et pas
une de plus : *ce que tu as fait*, *ce que tu as vu*, *ce que tu attendais*. Une capture vaut mieux
qu'une description pour tout ce qui est visuel. N'essaie pas de diagnostiquer — c'est mon travail, et
un diagnostic dans la colonne ✓ masque souvent le vrai symptôme.
**Ce qui n'est pas un bug** : la liste des limites connues en fin de document. À lire **avant** de
commencer, pas après avoir signalé l'une d'elles.
---
## 0. Prérequis — une fois ## 0. Prérequis — une fois
| # | Étape | Attendu | ✓ | | # | É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.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 | ✅ 16/09 — 9.0.1-full_build, alias winget dans le Path persistant |
| 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.2 | `dotnet user-secrets set "AI:ApiKey" "<clé Gemini>" --project manager-service/ManagerService` | `dotnet user-secrets list` montre `AI:ApiKey` (et `Studio:Fal:Key`) | ✅ 16/09 — « Successfully saved AI:ApiKey » (Thomas) |
| 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.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` | ✅ 16/09 — les 6 appliquées en une passe, « Done. » |
| 0.4 | `dotnet run` (manager-service), `flutter run -d chrome` (manager-app) | Les deux démarrent, connexion SuperAdmin OK | | | 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.5 | **Dialogue d'instance** — il n'est pas dans un écran : **pied du menu de gauche → icône ⇄ « Changer d'instance »** (visible seulement en SuperAdmin) → dans la liste, le **crayon** « Configurer le plan » de la ligne voulue. Activer **Assistant IA**, puis **Studio**, puis accorder **100 crédits** | Solde 100 dans la jauge du menu | |
| 0.5b | ⚠️ Dans ce même dialogue, **décocher Assistant IA** | L'interrupteur **Studio se désactive tout seul** et devient grisé : pas de Studio sans assistant (décision 12). Recocher les deux avant de continuer | |
| 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.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 | | | 0.7 | Donner un **portrait** à Léon (onglet Visage → générer ou choisir une vue Portrait) | La vue Portrait est enregistrée | |
--- ---
## 0bis. Le socle d'upload — ajouté le 2026-09-16
> **Pourquoi cette section n'existait pas, et pourquoi elle doit exister.** Le lot 0 a **réécrit tout le
> chemin d'upload** de manager-app : plus aucun appel au SDK Firebase, tout passe par une URL V4 signée
> puis `POST /ingest`. C'est codé depuis le 13/09 et couvert par des tests unitaires, mais **aucun
> fichier n'a jamais été déposé depuis un navigateur**. Les § 1 à 5 supposent tous que ça marche : la
> médiathèque reçoit les images générées, les MP3 de narration, les portraits. Tester la narration
> avant ce socle, c'est chercher une fuite au toit avant d'avoir regardé les fondations.
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 0b.1 | Médiathèque : déposer une **image JPEG** de plus de 2560 px | Elle monte (barre de progression), apparaît dans la grille, s'ouvre. Réduite à 2560 px côté long | |
| 0b.2 | Déposer un **PNG à transparence** | Reste un PNG, l'alpha est préservé (le voir sur fond coloré) | |
| 0b.3 | Déposer un **MP3**, un **PDF** | Montent tels quels, lisibles / téléchargeables depuis la fiche | |
| 0b.4 | **Remplacer** le fichier d'une ressource existante | Le nouveau s'affiche partout où la ressource était utilisée, l'ancien ne traîne pas | |
| 0b.5 | **Supprimer** une ressource non utilisée | Disparaît de la grille ; recharger la page ne la ramène pas | |
| 0b.6 | ⚠️ Fichier **au-dessus du plafond de son type** (ex. une image > 30 Mo) | Refusé avec un message traduit, **pas** une erreur brute ni un échec muet. Tester en FR, EN **et** NL | |
| 0b.7 | ⚠️ **Sélecteur de ressource** : champ image d'une section → ajouter un fichier depuis la modale → Enregistrer | Le fichier arrive **et** le champ de la section le pointe. C'est le piège historique : la modale embarque `ResourcesScreen`, une régression ici touche les 13 types de section | |
| 0b.8 | Ouvrir une ressource déposée depuis **visitapp-web** et depuis **mymuseum-visitapp** | L'URL s'ouvre des deux côtés (le jeton de téléchargement est bien posé à l'écriture) | |
| 0b.9 | Remplacer l'image d'une visite **déjà téléchargée** dans mymuseum-visitapp | L'app la re-télécharge, n'affiche pas l'ancienne | |
| 0b.10 | Sélecteur de langues d'une configuration | **Le luxembourgeois (LB) est proposé**, avec son drapeau (ajouté au lot 0, jamais vu à l'écran) | |
---
## 1. Mention IA dans le fichier (lot 6) ## 1. Mention IA dans le fichier (lot 6)
| # | Étape | Attendu | ✓ | | # | Étape | Attendu | ✓ |
@ -49,6 +96,25 @@
--- ---
## 2bis. Les crédits, pour de vrai — ajouté le 2026-09-16
> Le § 2 vérifie que **l'estimation** affichée est juste. Cette section-ci vérifie que **le solde**
> bouge juste, ce qui n'est pas la même chose : le mécanisme est une réservation puis un débit réel,
> avec remboursement en cas d'échec, et rien de tout ça n'a tourné en conditions réelles.
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 2b.1 | Noter le solde, lancer une génération de 3 variantes, **regarder la jauge pendant** le travail | Le solde baisse **dès le lancement** (réservation), pas à l'arrivée des images | |
| 2b.2 | À la fin | Le débit définitif égale l'estimation annoncée, ni plus ni moins | |
| 2b.3 | Fermer le panneau **sans valider** aucune variante | Les crédits restent dépensés (l'appel a coûté), mais **rien** ne s'ajoute au quota de stockage | |
| 2b.4 | Lancer **deux générations coup sur coup** sans attendre la première | Les deux réservations se cumulent ; le solde ne descend jamais sous zéro | |
| 2b.5 | Solde insuffisant pour 3 variantes mais suffisant pour 1 | Refus **avant** l'appel, message clair, aucun crédit pris | |
| 2b.6 | ⚠️ Génération qui échoue (couper le réseau du serveur vers fal, ou fausse clé) | Le solde **revient** à sa valeur d'avant. C'est le point le plus important de la section | |
| 2b.7 | Abonnement journal des crédits | Une ligne par mouvement (réservation, débit, remboursement), export CSV lisible | |
| 2b.8 | SuperAdmin : accorder 50 crédits à l'instance | Solde +50, et la **date d'expiration repoussée à +12 mois sur tout le solde** (pas seulement sur les 50) | |
---
## 3. Qui raconte quoi (lot 8a et 8c) ## 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. Préparer une configuration avec un **parcours de 4 étapes** (titre + description remplis en FR et NL) et un **article** avec du texte.
@ -159,3 +225,23 @@ ajouter **La cuisinière sans portrait** comme narratrice d'une étape).
- Hors ligne, mymuseum-visitapp ne montre pas le portrait. - 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. - 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. - Les extraits d'écoute des voix (lot 7) ne sont pas encore produits.
---
## Ce que ce plan ne couvre **pas** — ajouté le 2026-09-16
À savoir avant de conclure « le Studio est testé » :
- **L'infra du bucket de prod.** Le lot 0 est codé mais son volet infra reste ouvert : CORS des origines
de prod, cycle de vie `incoming/`, et surtout **fermeture des règles Storage en écriture** — qui doit
se faire *après* le déploiement du nouveau manager-app, sinon l'upload de la version encore en ligne
casse. Rien de tout ça ne se voit depuis un poste de dev.
- **Le plafond journalier par utilisateur** : il *est* configurable, champ « Plafond par utilisateur et
par jour (crédits, 0 = aucun) » dans le dialogue d'instance, sous l'interrupteur Studio. Il vaut 0 par
défaut, donc rien ne se déclenche tant qu'on n'y met pas une valeur — à tester le jour où on veut
vérifier le garde-fou « stagiaire », pas dans cette passe.
- **Le webhook fal** en conditions réelles : en local `Studio:WebhookBaseUrl` est vide, la relecture se
fait par sondage. La vérification de signature ED25519 ne sera exercée qu'en préprod.
- **La charge** : tout ce plan se passe à un seul utilisateur. Le sémaphore à 2 images simultanées de
l'ingestion ne se voit pas à cette échelle.
- **Les lots 9 à 11** (avant/après, vidéo, 3D) : pas commencés.

View File

@ -161,6 +161,7 @@ Tout sur `Instance`, dupliqué depuis `SubscriptionPlan` : `StorageQuotaBytes`,
| 22 | Plafond dur et dotation de plan — *13/09, lot 1* | **Retirés.** Avec des crédits prépayés (décision 17), le solde *est* le plafond : un « plafond dur organisation » n'a plus rien à borner. Seul reste `StudioPerUserDailyCap`, en crédits. `SubscriptionPlan.HasStudio` / `StudioCreditsGranted` et `Instance.StudioProviderRegion` ne sont pas créés tant que rien ne les lit (prix différés, décision 16 ; région déclarative) | | 22 | Plafond dur et dotation de plan — *13/09, lot 1* | **Retirés.** Avec des crédits prépayés (décision 17), le solde *est* le plafond : un « plafond dur organisation » n'a plus rien à borner. Seul reste `StudioPerUserDailyCap`, en crédits. `SubscriptionPlan.HasStudio` / `StudioCreditsGranted` et `Instance.StudioProviderRegion` ne sont pas créés tant que rien ne les lit (prix différés, décision 16 ; région déclarative) |
| 23 | Écarts des lots 2 à 4 — *13/09* | **Fournisseur interchangeable** : `IGenerationProvider` (soumission, sondage, lecture du webhook) + `GenerationModel.ProviderKey` en base ; changer de fournisseur = une classe et une ligne de catalogue. **Une requête par variante** (FLUX.2 pro n'a pas de `num_images`). **Référence source avant celles de l'identité** dans l'ordre d'envoi. Migration unique `AddStudioGeneration` pour les lots 2 et 3. **Non livrés** : `preview`, `GET /jobs`, `cancel`, `reject`, `regenerate`, l'interrupteur UI de `CanValidateAssets`. **Pas de `target` serveur** : l'onglet « Générer » vit dans le sélecteur (`showSelectResourceModal`, mode sélection) et la ressource validée revient au champ qui l'a ouvert — donc absent du bouton « Ajouter » de la Médiathèque, qui relève du lot 5. Surcharges d'identité en puces, pas en onglets ; `ModelKey` non exposé (un seul modèle). **`GuidedStep.ImageUrl` conservée** et remplie par le client avec `ImageResourceId` (motif `GeoPoint`) : visitapp-web n'a rien à changer, mymuseum-visitapp lit l'id pour l'hors-ligne, tablet-app n'a pas d'étapes. `CreditCost = 10` provisoire jusqu'au calibrage || 24 | Modèles 3D et multi-vues — *15/09* | **Tout reste sur fal** : Meshy (v5, v6-preview, v7), Hi3D, Tripo3D, Hunyuan3D et Trellis y sont exposés — aucun second fournisseur à intégrer, aucune seconde clé. **Deux lignes de catalogue, pas une** : `3d-default` mono-image (~0,020,05 $) et `3d-multiview` = `meshy/v7/multi-image-to-3d` (~1,20 $). Le facteur ~25 sur le coût impose que le basculement de l'un à l'autre soit **annoncé avant la génération, jamais silencieux**. Hi3D n'expose pas de multiview : excellent en fidélité structurelle, il ne couvre pas le cas d'usage central. Ids et prix à revérifier au calibrage (§9.4) | | 23 | Écarts des lots 2 à 4 — *13/09* | **Fournisseur interchangeable** : `IGenerationProvider` (soumission, sondage, lecture du webhook) + `GenerationModel.ProviderKey` en base ; changer de fournisseur = une classe et une ligne de catalogue. **Une requête par variante** (FLUX.2 pro n'a pas de `num_images`). **Référence source avant celles de l'identité** dans l'ordre d'envoi. Migration unique `AddStudioGeneration` pour les lots 2 et 3. **Non livrés** : `preview`, `GET /jobs`, `cancel`, `reject`, `regenerate`, l'interrupteur UI de `CanValidateAssets`. **Pas de `target` serveur** : l'onglet « Générer » vit dans le sélecteur (`showSelectResourceModal`, mode sélection) et la ressource validée revient au champ qui l'a ouvert — donc absent du bouton « Ajouter » de la Médiathèque, qui relève du lot 5. Surcharges d'identité en puces, pas en onglets ; `ModelKey` non exposé (un seul modèle). **`GuidedStep.ImageUrl` conservée** et remplie par le client avec `ImageResourceId` (motif `GeoPoint`) : visitapp-web n'a rien à changer, mymuseum-visitapp lit l'id pour l'hors-ligne, tablet-app n'a pas d'étapes. `CreditCost = 10` provisoire jusqu'au calibrage || 24 | Modèles 3D et multi-vues — *15/09* | **Tout reste sur fal** : Meshy (v5, v6-preview, v7), Hi3D, Tripo3D, Hunyuan3D et Trellis y sont exposés — aucun second fournisseur à intégrer, aucune seconde clé. **Deux lignes de catalogue, pas une** : `3d-default` mono-image (~0,020,05 $) et `3d-multiview` = `meshy/v7/multi-image-to-3d` (~1,20 $). Le facteur ~25 sur le coût impose que le basculement de l'un à l'autre soit **annoncé avant la génération, jamais silencieux**. Hi3D n'expose pas de multiview : excellent en fidélité structurelle, il ne couvre pas le cas d'usage central. Ids et prix à revérifier au calibrage (§9.4) |
| 25 | Casting visiteur — *15/09* | **Retenu** — lève le « hors périmètre » de §3.8. `ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured }` ne porte **que** l'ordre et la mise en avant : l'appartenance reste **calculée** depuis les narrateurs assignés. Un personnage ne s'assigne qu'à **un seul endroit — le contenu** ; l'écran casting est une *vue*, jamais une seconde saisie. Rien ne s'affiche côté visiteur sans `Configuration.IsCastingShownToVisitor` (défaut off). Lot 8bis | | 25 | Casting visiteur — *15/09* | **Retenu** — lève le « hors périmètre » de §3.8. `ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured }` ne porte **que** l'ordre et la mise en avant : l'appartenance reste **calculée** depuis les narrateurs assignés. Un personnage ne s'assigne qu'à **un seul endroit — le contenu** ; l'écran casting est une *vue*, jamais une seconde saisie. Rien ne s'affiche côté visiteur sans `Configuration.IsCastingShownToVisitor` (défaut off). Lot 8bis |
| 26 | Prise de vue mobile — *16/09* | **Page de dépôt dédiée ouverte par QR code, pas un manager-app responsive.** Le conservateur est devant la vitrine avec son téléphone, pas devant son poste ; rendre responsive un back-office Flutter web pour un seul écran revient à le rendre responsive tout court — refus déjà posé sur le routing (décision 23) — et CanvasKit se charge mal sur la 4G d'une salle. La page vit sur **la même surface web non-Flutter que l'éditeur de POI** (décision n°9 d'[immersif-frontiere-plan.md](immersif-frontiere-plan.md)) : aucune nouvelle cible de déploiement. Capture par `<input type="file" accept="image/*" capture="environment">`, qui ouvre l'appareil photo natif — **jamais `getUserMedia`**, dont le flux est sans autofocus ni traitement constructeur, alors que c'est le seul gabarit du Studio où la qualité des photos pèse plus lourd que le prompt. Accès par **token de dépôt** borné (§3.7bis), jamais un JWT manager. **Lot 11bis, après le lot 11** : le dépôt de fichiers depuis le poste couvre déjà le besoin fonctionnel |
--- ---
@ -731,6 +732,39 @@ publiques exposées en JWKS :
répond `200` sans rien refaire. D'où le téléchargement des octets dans un job, pas dans la requête ; répond `200` sans rien refaire. D'où le téléchargement des octets dans un job, pas dans la requête ;
- corps : `{ request_id, gateway_request_id, status: "OK" | "ERROR", payload | error }`. - corps : `{ request_id, gateway_request_id, status: "OK" | "ERROR", payload | error }`.
### 3.7bis Token de dépôt — le seul chemin d'écriture non authentifié
Réservé au lot 11bis (décision 26). À lire avant de l'écrire : ce serait **le seul endroit de la
solution où un client sans session peut faire écrire dans le bucket**, et les règles Storage de prod
sont ouvertes en écriture depuis 2024. Un token laxiste transformerait le QR en hébergeur de fichiers
gratuit pour qui le photographie par-dessus l'épaule.
```
StudioSourceToken
Token -- aléatoire 32 octets, indexé, jamais dérivé d'un id
JobDraftId -- le brouillon de génération 3D, et lui seul
InstanceId
SlotsLeft -- 4 au départ, décrémenté à chaque URL délivrée
ExpiresAt -- maintenant + 30 min : la durée d'une prise de vue
CreatedBy -- l'utilisateur qui a affiché le QR, pour le journal
```
Quatre bornes, toutes côté serveur :
- **portée d'un seul brouillon** — il n'autorise que le dépôt dans
`studio-sources/{instanceId}/{draftId}/{slot}`, aucune lecture, aucun autre chemin ;
- **durée courte** — 30 min, cohérent avec les 15 min des URLs signées de la décision 14 ;
- **compteur borné** — 4 URLs délivrées, puis le token est mort, même avant expiration ;
- **il ne signe rien lui-même** — il ouvre `POST /api/Studio/source-upload-url`, qui applique le
plafond de taille (`x-goog-content-length-range`) et le quota exactement comme le flux authentifié.
Le token est une autorisation, pas une capacité.
**Les photos sources ne sont pas des `Resource`.** Ce sont des entrées de génération : elles vivent
sous `studio-sources/`, hors quota de médiathèque, et tombent avec une règle de cycle de vie comme
celle posée sur `incoming/` au lot 0. Elles ne passent donc **pas** par `ResourceIngestionService`
ce qui veut dire que **le lot 0, déjà codé le 13/09, n'est pas à rouvrir**. Seul le GLB produit
devient une `Resource`.
### 3.8 Personnages — fusion des trois plans, arrêtée le 2026-09-01 ### 3.8 Personnages — fusion des trois plans, arrêtée le 2026-09-01
> **Trois plans décrivaient le même objet sans se croiser.** Le canon visuel (ce plan), les 3 frames > **Trois plans décrivaient le même objet sans se croiser.** Le canon visuel (ce plan), les 3 frames
@ -1526,6 +1560,10 @@ l'écran doit être construit autour de ça.
Chaque case porte une silhouette d'exemple. Sous le bloc, **une seule consigne**, courte : Chaque case porte une silhouette d'exemple. Sous le bloc, **une seule consigne**, courte :
*même objet, même lumière, fond neutre, objet entier dans le cadre.* C'est la recommandation du *même objet, même lumière, fond neutre, objet entier dans le cadre.* C'est la recommandation du
modèle, et elle pèse plus lourd que n'importe quel réglage. modèle, et elle pèse plus lourd que n'importe quel réglage.
Chaque case accepte un fichier **ou** une photo prise sur place (lot 11bis). ⚠️ **La grille de
quatre est aussi un plafond, pas une mise en forme** : `meshy/v7/multi-image-to-3d` prend **2 à 4
vues**, une cinquième fait rejeter l'appel.
3. **Le coût se met à jour en direct, et le changement de modèle est annoncé** : 1 photo → modèle 3. **Le coût se met à jour en direct, et le changement de modèle est annoncé** : 1 photo → modèle
mono-image ; dès la 2ᵉ → Meshy v7 multi, **~25× plus cher**. Une ligne explicite au moment où mono-image ; dès la 2ᵉ → Meshy v7 multi, **~25× plus cher**. Une ligne explicite au moment où
la bascule se produit, jamais une facture découverte après coup (même principe que le coût de la bascule se produit, jamais une facture découverte après coup (même principe que le coût de
@ -1533,6 +1571,22 @@ l'écran doit être construit autour de ça.
4. À l'inverse, **ne pas culpabiliser le mono-image** : une seule photo reste un usage légitime et 4. À l'inverse, **ne pas culpabiliser le mono-image** : une seule photo reste un usage légitime et
bon marché pour une illustration. La formulation est « plus de vues = plus fidèle », pas bon marché pour une illustration. La formulation est « plus de vues = plus fidèle », pas
« vous avez mal fait ». « vous avez mal fait ».
4bis. **Les N photos sources ne touchent pas `GenerationJob.SourceResourceId`.** Vérifié dans le code le
16/09 : cette colonne est **singulière** et porte un id de `Resource`
(`AddStudioGeneration:47`, `StudioGenerationService.cs:238`) — l'élargir en liste rouvrirait les lots 3
et 4, leurs tests et le chemin image qui fonctionne. À la place, une **table fille** dans la migration
du lot 11 :
```
StudioSourceFile
JobDraftId -- le brouillon de génération
Slot -- Face | Profil | Dos | TroisQuarts
StoragePath -- studio-sources/{instanceId}/{draftId}/{slot}
```
`SourceResourceId` reste intact pour le chemin image. C'est le pendant en entrée du multi-fichier en
sortie, et ça suit la décision 21, qui réserve déjà le job composite à la 3D.
5. Génération asynchrone en **job composite** (parent + enfants, décision 21), puis **normalisation 5. Génération asynchrone en **job composite** (parent + enfants, décision 21), puis **normalisation
canonique du GLB** à l'ingestion — échelle, pivot, orientation (décision n°2 d' canonique du GLB** à l'ingestion — échelle, pivot, orientation (décision n°2 d'
`immersif-frontiere-plan` §5) : sans elle les POI posés sur un modèle sautent à la `immersif-frontiere-plan` §5) : sans elle les POI posés sur un modèle sautent à la
@ -1543,6 +1597,33 @@ l'écran doit être construit autour de ça.
7. La ressource validée revient au champ qui a ouvert le sélecteur, et les **POI se posent dans 7. La ressource validée revient au champ qui a ouvert le sélecteur, et les **POI se posent dans
l'éditeur three.js en iframe** (décision n°9) — le vrai morceau non trivial du chantier. l'éditeur three.js en iframe** (décision n°9) — le vrai morceau non trivial du chantier.
### Lot 11bis — Prise de vue mobile par QR *(petit, juste après le lot 11)*
Décision 26. Le lot 11 suppose que le conservateur a déjà ses quatre JPEG sur son disque ; devant une
vitrine, il a un téléphone. Ce lot ferme l'écart **sans toucher au responsive de manager-app**.
1. Bouton « **Photographier avec mon téléphone** » à côté de la grille des quatre emplacements. Il
crée un `StudioSourceToken` (§3.7bis) et affiche le QR de l'URL de dépôt.
2. **Page de dépôt** — même surface web non-Flutter que l'éditeur de POI, route
`/studio/capture/{token}`. Quatre cases (Face / Profil / Dos / 3/4), la même consigne unique que
sur le poste, un `<input capture="environment">` par case. Pas de session, pas de routing, pas
d'état : demander l'URL signée, `PUT`, afficher la vignette.
3. **Le poste sert d'écran de contrôle** : les vignettes se remplissent en direct pendant la prise de
vue, par le **polling 2 s déjà retenu au §3.7** — aucun SSE à introduire pour ça.
4. Token expiré ou épuisé → la page le dit et renvoie vers le poste pour régénérer le QR. Le bouton
« Générer » reste sur le poste : **le téléphone dépose, il ne dépense pas de crédits.**
⚠️ **Le réseau est le point faible, pas la photo.** Une salle d'exposition en sous-sol coupe la 4G,
et une page web sans service worker perd les clichés déjà pris. Acceptable ici — l'utilisateur est un
conservateur qui recommence, pas un visiteur — mais c'est *cet* argument qui ferait revenir un jour
l'idée d'une application native, jamais la qualité d'image.
**Ne pas inverser l'ordre avec le lot 11.** « Je photographie, je m'envoie les photos, j'uploade
depuis le poste » coûte zéro et marche dès le premier jour. La valeur du chantier est dans la qualité
des quatre vues, l'annonce du basculement mono→multi à ×25 et la normalisation canonique du GLB.
---
⚠️ **Ce lot faisait quatre lignes et se contentait de renvoyer au plan VR, qui lui-même renvoyait ⚠️ **Ce lot faisait quatre lignes et se contentait de renvoyer au plan VR, qui lui-même renvoyait
ici.** La frontière est tranchée depuis le 2026-09-11 dans ici.** La frontière est tranchée depuis le 2026-09-11 dans
[immersif-frontiere-plan.md](immersif-frontiere-plan.md), qui ajoute : `Scene3D` comme [immersif-frontiere-plan.md](immersif-frontiere-plan.md), qui ajoute : `Scene3D` comme
@ -1592,6 +1673,9 @@ la première migration du Studio, pas en arrivant au lot 11.
| « Tout par le serveur » (première version de la décision 14) | `MemoryBufferThreshold = int.MaxValue` (`Startup.cs:112`) tient tout `IFormFile` en RAM, et une vidéo 360 de 1 Go traverserait le VPS | Abandonné le jour même : les octets vont directement chez Google | | « Tout par le serveur » (première version de la décision 14) | `MemoryBufferThreshold = int.MaxValue` (`Startup.cs:112`) tient tout `IFormFile` en RAM, et une vidéo 360 de 1 Go traverserait le VPS | Abandonné le jour même : les octets vont directement chez Google |
| Objet écrit par le SDK GCS | Pas d'URL de téléchargement Firebase sans la métadonnée `firebaseStorageDownloadTokens` | Jeton posé à l'écriture, URL construite (§5) | | Objet écrit par le SDK GCS | Pas d'URL de téléchargement Firebase sans la métadonnée `firebaseStorageDownloadTokens` | Jeton posé à l'écriture, URL construite (§5) |
| Trois décisions d'`immersif-frontiere-plan` §5 « à écrire avant la première migration » | Seule la n°5 (crédits) avait été reportée | N°1, 4 et 6 reportées le 13/09 : décisions 14, 20, 21 | | Trois décisions d'`immersif-frontiere-plan` §5 « à écrire avant la première migration » | Seule la n°5 (crédits) avait été reportée | N°1, 4 et 6 reportées le 13/09 : décisions 14, 20, 21 |
| « Rendre manager-app responsive pour photographier l'objet » | Back-office Flutter web : le responsive d'un seul écran entraîne celui du routing, déjà exclu du périmètre (décision 23) — et CanvasKit à charger sur la 4G d'une salle pour faire un `<input type="file">` | Page de dépôt dédiée ouverte par QR, sur la surface web de l'éditeur de POI (décision 26, lot 11bis) |
| « Capture par `getUserMedia` » | Flux vidéo sans autofocus ni traitement constructeur, et manager-app n'a aucun binding caméra JS-interop | `<input type="file" accept="image/*" capture="environment">` : appareil photo natif, JPEG pleine résolution, retombe sur le sélecteur de fichiers sur poste |
| « Les photos sources passent par l'ingestion du lot 0 » | Le lot 0 est **codé depuis le 13/09**, et une photo source n'est pas une `Resource` : c'est une entrée de génération | `studio-sources/`, hors quota médiathèque, balayé par le cycle de vie. Lot 0 non rouvert |
--- ---
@ -1662,6 +1746,13 @@ la première migration du Studio, pas en arrivant au lot 11.
`x-goog-content-length-range` — sans quoi le navigateur refuse l'envoi. `x-goog-content-length-range` — sans quoi le navigateur refuse l'envoi.
- **Cycle de vie** : règle `age: 1` sur le préfixe `incoming/` — un envoi jamais ingéré disparaît seul, - **Cycle de vie** : règle `age: 1` sur le préfixe `incoming/` — un envoi jamais ingéré disparaît seul,
sans job Hangfire. S'ajoute à la règle des versions non courantes posée le 07/09. sans job Hangfire. S'ajoute à la règle des versions non courantes posée le 07/09.
- **Cycle de vie à prévoir dans le même geste** : règle équivalente (`age: 7`) sur `studio-sources/`, le
préfixe des photos sources de la 3D (lot 11bis, §3.7bis). Rien ne les balaie autrement : ce ne sont
pas des `Resource`. À poser maintenant coûte une ligne, plus tard coûte de le redécouvrir.
- **Origine de la page de capture dans le CORS** — case à cocher, pas encore une valeur : la config CORS
est au niveau du bucket et ne liste que les origines du manager. Tant que l'hébergement de la surface
web non-Flutter (éditeur POI + page de capture) n'est pas tranché, on ne sait pas si c'est une origine
de plus ou la même. À revérifier au lot 11bis.
- **Règles Storage : écriture fermée à tous les clients**, lecture inchangée. Relever les règles - **Règles Storage : écriture fermée à tous les clients**, lecture inchangée. Relever les règles
actuelles avant. ⚠️ À appliquer **après** la mise en prod du nouveau manager-app : fermer avant actuelles avant. ⚠️ À appliquer **après** la mise en prod du nouveau manager-app : fermer avant
casse l'upload de la version encore déployée. casse l'upload de la version encore déployée.