diff --git a/CLAUDE.md b/CLAUDE.md index bd94be3..2749051 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,18 +60,54 @@ Le décodage EAN-13 se fait en **C# avec [ZXing.Net](https://www.nuget.org/packa |---|---| | Décodage EAN-13 propre (380×160) | 0,04 ms/frame | | **Pire cas** : frame 640×480 bruitée sans code-barres (échec) | 0,53 ms/frame | -| Surcoût du payload PWA | +192 Ko (brotli) | +| Poids de l'assembly `zxing.wasm` seul | +192 Ko (brotli) | Le pire cas est le chiffre qui gouverne le framerate : la majorité des frames caméra ne contiennent pas de code-barres lisible, et c'est l'échec de décodage qui coûte le plus cher. -⚠️ Ces chiffres sont mesurés en **JIT x64 natif**. En Blazor WASM le code est *interprété* par défaut : compter un facteur ~10-20×, soit ~5-10 ms/frame — largement suffisant pour scanner à 10-15 fps. Activer `true` ramène ça à 1-2 ms, au prix d'un build nettement plus lent. +### Mesures en WASM réel — faites le 2026-08-18, phase scan + +L'estimation « ~5-10 ms/frame en interprété » ci-dessus n'était qu'une extrapolation. Elle a +été **vérifiée dans un vrai navigateur** (Chromium 148, x86_64 de bureau), sur le publish +`Release` du client, en appelant le décodeur depuis la console via `BancEssaiScan` : + +| Configuration | Pire cas 640×288 (bande visée) | Pire cas 640×480 | +|---|---|---| +| Publish `Release`, interprété | **4,4 – 6,4 ms/frame** | 6,7 – 7,8 ms/frame | +| Build `Debug`, interprété | 17,7 ms/frame | 28,7 ms/frame | + +**L'estimation était bonne** : le mode interprété tient largement les 10-15 fps visés (une +frame toutes les 80 ms n'utilise que ~6 % du budget). **Aucune raison d'activer l'AOT** — +il reste à confirmer sur un téléphone, sensiblement plus lent qu'un x86_64 de bureau. + +⚠️ Ne jamais juger la fluidité sur un build `Debug` : il est **4 à 5× plus lent** que le +`Release`, de quoi conclure à tort qu'il faut l'AOT. + +### ⚠️ Le surcoût de payload réel est bien supérieur à 192 Ko + +Mesuré par différence entre deux publish `Release` complets (somme brotli de `_framework`) : +**+351 Ko** au total, dont ~4,7 Ko de code applicatif. Le coût imputable à ZXing.Net est donc +d'environ **+346 Ko brotli**, soit **1,8× le poids de son propre assembly**. Le surplus vient +des assemblies BCL que le trimmer ne peut plus retirer : + +| Assembly | Delta brotli | +|---|---| +| `zxing.wasm` | +192 495 o | +| `System.Text.RegularExpressions` | +91 237 o (de 7 Ko à 98 Ko : ZXing utilise Regex, tout le moteur reste) | +| `System.Runtime.Numerics` | +30 701 o (nouveau) | +| `System.Private.CoreLib` | +17 586 o | +| divers (`Threading`, `Collections`, `InteropServices`…) | ~14 Ko | + +Ne pas reprendre « +192 Ko » comme coût du scan : c'est le poids de l'assembly, pas celui +de la fonctionnalité. ### Pièges à connaître - **Le JS interop ne disparaît pas.** `getUserMedia` et ``/`getImageData` sont des API web sans équivalent C#. Prévoir ~30 lignes de JS maison dont le seul rôle est de pousser un `byte[]` vers C#. Toute la logique de décodage reste en C#. - **Ne pas perdre de temps à essayer de réduire la taille via un reader ciblé.** Remplacer `MultiFormatReader` par `EAN13Reader` pour aider le trimmer **ne change rien** : mesuré à 192 495 octets à l'octet près dans les deux cas. ZXing.Net n'est pas trim-friendly. - `RGBLuminanceSource` accepte directement le buffer RGBA du canvas (`BitmapFormat.RGBA32`) — **aucune bibliothèque d'image nécessaire** (pas de SkiaSharp ni ImageSharp). -- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). +- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). Corollaire vérifié : tester le scan depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera toujours** — ce n'est pas un contexte sécurisé. Le composant détecte ce cas et le dit explicitement. +- **Une `DOMException` perd son `name` en traversant le pont JS→C#** : `getUserMedia` refusé remonte en C# sous la forme « Permission denied undefined », sans `NotAllowedError`. Or c'est ce nom qui distingue « permission refusée » de « pas de caméra » de « caméra occupée ». Le JS doit donc **attraper l'erreur et renvoyer un code de statut** ; parser le message côté C# ne marche pas. +- **L'import du module JS doit porter une chaîne de requête** (`./js/scanner-camera.js?m=1`). En développement, l'import map généré par Blazor réécrit le chemin vers un nom empreinté que l'API hôte ne sert pas (elle utilise `UseStaticFiles`, qui ignore les points d'entrée empreintés) : l'import part en 404 et le scan ne démarre jamais. La chaîne de requête empêche cette réécriture ; en publication rien n'est réécrit pour ce fichier, donc le comportement est identique. ### Squelette validé diff --git a/MaBibli.Client/Composants/ScannerCodeBarres.razor b/MaBibli.Client/Composants/ScannerCodeBarres.razor index 108adc3..9fb606e 100644 --- a/MaBibli.Client/Composants/ScannerCodeBarres.razor +++ b/MaBibli.Client/Composants/ScannerCodeBarres.razor @@ -67,6 +67,14 @@ private enum Etat { Demarrage, Actif, Erreur } + // Le « ?m=1 » n'est pas cosmétique. En développement, l'import map généré par Blazor + // fait pointer « ./js/scanner-camera.js » vers un nom empreinté que l'API hôte ne sert + // pas (elle utilise UseStaticFiles, qui ignore les points d'entrée empreintés) : l'import + // échoue en 404 et le scan ne démarre jamais. Une chaîne de requête empêche l'import map + // de réécrire le chemin ; en publication, aucune réécriture n'est générée pour ce fichier, + // donc le comportement est identique. Vérifié dans les deux modes. + private const string CheminModule = "./js/scanner-camera.js?m=1"; + // ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps. private const int IntervalleMs = 80; @@ -96,7 +104,7 @@ try { - _module = await JS.InvokeAsync("import", "./js/scanner-camera.js"); + _module = await JS.InvokeAsync("import", CheminModule); var diagnostic = await _module.InvokeAsync("diagnostic"); if (diagnostic != "ok") @@ -114,29 +122,32 @@ return; } - await _module.InvokeVoidAsync("demarrer", _video); + // On ne laisse jamais un écran noir sans explication : chaque code dit précisément + // ce qui manque à l'utilisateur pour que le scan fonctionne. + var statut = await _module.InvokeAsync("demarrer", _video); + if (statut != "ok") + { + Echouer(statut switch + { + "permission-refusee" => + "L'accès à la caméra a été refusé. Autorisez-le dans les réglages du navigateur, " + + "ou saisissez l'ISBN à la main.", + "aucune-camera" => + "Aucune caméra utilisable n'a été trouvée sur cet appareil. Saisissez l'ISBN à la main.", + "camera-occupee" => + "La caméra est déjà utilisée par une autre application. Fermez-la, ou saisissez l'ISBN à la main.", + _ => $"La caméra n'a pas pu démarrer ({statut[(statut.IndexOf(':') + 1)..].Trim()}). " + + "Saisissez l'ISBN à la main.", + }); + return; + } + _etat = Etat.Actif; StateHasChanged(); _boucle = new CancellationTokenSource(); _ = BoucleAsync(_boucle.Token); } - catch (JSException ex) - { - // On ne laisse jamais un écran noir sans explication : le nom de l'erreur DOM - // dit précisément ce qui manque à l'utilisateur pour que ça marche. - Echouer(NomErreurDom(ex.Message) switch - { - "NotAllowedError" or "SecurityError" => - "L'accès à la caméra a été refusé. Autorisez-le dans les réglages du navigateur, " - + "ou saisissez l'ISBN à la main.", - "NotFoundError" or "DevicesNotFoundError" or "OverconstrainedError" => - "Aucune caméra utilisable n'a été trouvée sur cet appareil. Saisissez l'ISBN à la main.", - "NotReadableError" or "TrackStartError" => - "La caméra est déjà utilisée par une autre application. Fermez-la, ou saisissez l'ISBN à la main.", - _ => $"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main.", - }); - } catch (Exception ex) { Echouer($"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main."); @@ -216,13 +227,6 @@ StateHasChanged(); } - private static string NomErreurDom(string message) - { - // Blazor sérialise l'erreur DOM sous la forme "NotAllowedError: Permission denied". - var separateur = message.IndexOf(':'); - return separateur > 0 ? message[..separateur].Trim() : message.Trim(); - } - private async Task ArreterCameraAsync() { _boucle?.Cancel(); diff --git a/MaBibli.Client/wwwroot/js/scanner-camera.js b/MaBibli.Client/wwwroot/js/scanner-camera.js index 7c232a4..897c43b 100644 --- a/MaBibli.Client/wwwroot/js/scanner-camera.js +++ b/MaBibli.Client/wwwroot/js/scanner-camera.js @@ -16,19 +16,40 @@ export function diagnostic() { return 'ok'; } +// Renvoie un code de statut plutôt que de laisser remonter l'exception : une DOMException +// traversée par le pont JS→C# perd son `name` (mesuré : C# ne reçoit que « Permission denied +// undefined »), or c'est précisément ce nom qui dit à l'utilisateur ce qui lui manque. export async function demarrer(element) { video = element; - // facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi. - // "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale. - flux = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, - audio: false, - }); + try { + // facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi. + // "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale. + flux = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + } catch (e) { + switch (e.name) { + case 'NotAllowedError': + case 'SecurityError': + return 'permission-refusee'; + case 'NotFoundError': + case 'DevicesNotFoundError': + case 'OverconstrainedError': + return 'aucune-camera'; + case 'NotReadableError': + case 'TrackStartError': + return 'camera-occupee'; + default: + return 'erreur:' + (e.name || 'Error') + ' — ' + (e.message || ''); + } + } video.srcObject = flux; video.setAttribute('playsinline', ''); await video.play(); canvas = document.createElement('canvas'); ctx = canvas.getContext('2d', { willReadFrequently: true }); + return 'ok'; } // Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si