From f7ad9582f68045b5b9e1af7526d7bb633e7bf5d9 Mon Sep 17 00:00:00 2001 From: mathieu Date: Mon, 17 Aug 2026 23:32:05 +0200 Subject: [PATCH 1/2] Ajouter le scan camera du code-barres ISBN Le decodage EAN-13/EAN-8 est fait en C# par ZXing.Net (IsbnScanner), conformement a la decision actee dans CLAUDE.md : le JavaScript ne fournit que les pixels de la camera, aucune logique de decodage ne lui est confiee. Le scan se greffe en amont du flux d'ajout par ISBN existant : des qu'un code est lu et reconnu comme ISBN-13 valide, la recherche de notices est declenchee sans que l'utilisateur ait a retaper quoi que ce soit. La saisie manuelle reste accessible en permanence, y compris quand la camera echoue. Points traites explicitement : - camera arriere par defaut (facingMode "environment", en "ideal" pour ne pas echouer sur un PC sans camera arriere) ; - liberation de la camera a l'annulation, au succes et au demontage du composant ; - cadence limitee a ~12 images/s et decodage restreint a la bande centrale reduite a 640 px de large : inutile de decoder chaque frame en pleine taille ; - messages distincts pour permission refusee, absence de camera, camera occupee et contexte non securise (getUserMedia exige HTTPS ou localhost : une IP de reseau local en http ne marchera jamais, c'est une source de confusion garantie). Co-Authored-By: Claude Opus 5 --- .../Composants/ScannerCodeBarres.razor | 263 ++++++++++++++++++ MaBibli.Client/MaBibli.Client.csproj | 2 + MaBibli.Client/Pages/AjoutIsbn.razor | 30 +- MaBibli.Client/Scan/BancEssaiScan.cs | 40 +++ MaBibli.Client/Scan/IsbnScanner.cs | 53 ++++ MaBibli.Client/wwwroot/css/app.css | 33 +++ MaBibli.Client/wwwroot/js/scanner-camera.js | 61 ++++ MaBibli.Tests/IsbnScannerTests.cs | 102 +++++++ MaBibli.Tests/MaBibli.Tests.csproj | 3 + 9 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 MaBibli.Client/Composants/ScannerCodeBarres.razor create mode 100644 MaBibli.Client/Scan/BancEssaiScan.cs create mode 100644 MaBibli.Client/Scan/IsbnScanner.cs create mode 100644 MaBibli.Client/wwwroot/js/scanner-camera.js create mode 100644 MaBibli.Tests/IsbnScannerTests.cs diff --git a/MaBibli.Client/Composants/ScannerCodeBarres.razor b/MaBibli.Client/Composants/ScannerCodeBarres.razor new file mode 100644 index 0000000..108adc3 --- /dev/null +++ b/MaBibli.Client/Composants/ScannerCodeBarres.razor @@ -0,0 +1,263 @@ +@* + Scan du code-barres ISBN par la caméra. + + Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage + est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md. + + On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée + (voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la + main, et cela ménage la batterie du téléphone. +*@ +@using System.Diagnostics +@using MaBibli.Client.Scan +@using MaBibli.Shared.Isbn +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+ @if (_etat == Etat.Erreur) + { + + } + else + { +
+ + +
+ +

+ @if (_etat == Etat.Demarrage) + { + Ouverture de la caméra… + } + else + { + Visez le code-barres au dos du livre, bien à plat et bien éclairé. + } +

+ + @if (_avertissementFormat is not null) + { +

@_avertissementFormat

+ } + + @if (_framesDecodees > 0) + { + @* Mesure réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation + (~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté + sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@ +

+ Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames) +

+ } + } + +
+ +
+
+ +@code { + /// Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé. + [Parameter] public EventCallback OnCodeDetecte { get; set; } + + /// Appelé quand l'utilisateur renonce au scan. + [Parameter] public EventCallback OnAnnuler { get; set; } + + private enum Etat { Demarrage, Actif, Erreur } + + // ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps. + private const int IntervalleMs = 80; + + // La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser + // le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13. + private const int LargeurMax = 640; + + // Seule la bande centrale est décodée : c'est là que l'utilisateur vise. + private const double PartHauteur = 0.45; + + private ElementReference _video; + private IJSObjectReference? _module; + private CancellationTokenSource? _boucle; + private Etat _etat = Etat.Demarrage; + private string? _erreur; + private string? _avertissementFormat; + private long _framesDecodees; + private double _totalMs; + private double _msMoyen => _framesDecodees == 0 ? 0 : _totalMs / _framesDecodees; + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (!premierRendu) + { + return; + } + + try + { + _module = await JS.InvokeAsync("import", "./js/scanner-camera.js"); + + var diagnostic = await _module.InvokeAsync("diagnostic"); + if (diagnostic != "ok") + { + Echouer(diagnostic switch + { + // Piège garanti en développement : depuis un téléphone, pointer l'IP locale + // du PC (http://192.168.x.x) n'est PAS un contexte sécurisé pour le navigateur. + "contexte-non-securise" => + "La caméra n'est accessible qu'en HTTPS (ou sur localhost). " + + "En développement, ouvrir l'application par l'adresse IP du PC ne suffit pas. " + + "Saisissez l'ISBN à la main.", + _ => "Ce navigateur ne donne pas accès à la caméra. Saisissez l'ISBN à la main.", + }); + return; + } + + await _module.InvokeVoidAsync("demarrer", _video); + _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."); + } + } + + private async Task BoucleAsync(CancellationToken jeton) + { + try + { + while (!jeton.IsCancellationRequested) + { + await Task.Delay(IntervalleMs, jeton); + + if (_module is null || jeton.IsCancellationRequested) + { + return; + } + + var dimensions = await _module.InvokeAsync("capturer", LargeurMax, PartHauteur); + if (dimensions is null) + { + continue; // vidéo pas encore prête + } + + var pixels = await _module.InvokeAsync("pixels"); + + var depart = Stopwatch.GetTimestamp(); + var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]); + _totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds; + _framesDecodees++; + + if (code is null) + { + // Une frame sur dix suffit à rafraîchir la mesure sans repeindre en boucle. + if (_framesDecodees % 10 == 0) + { + StateHasChanged(); + } + continue; + } + + var isbn = IsbnUtils.Normaliser(code); + if (isbn is null || !IsbnUtils.EstIsbn13Valide(isbn)) + { + // Un EAN-8, ou l'EAN d'un produit non-livre : on le dit et on continue. + _avertissementFormat = $"Code lu (« {code} »), mais ce n'est pas un ISBN. Continuez à viser le code-barres du livre."; + StateHasChanged(); + continue; + } + + await ArreterCameraAsync(); + await OnCodeDetecte.InvokeAsync(isbn); + return; + } + } + catch (OperationCanceledException) + { + // Sortie normale : le composant a été quitté. + } + catch (JSDisconnectedException) + { + // La page se ferme, plus rien à libérer côté JS. + } + } + + private async Task AnnulerAsync() + { + await ArreterCameraAsync(); + await OnAnnuler.InvokeAsync(); + } + + private void Echouer(string message) + { + _erreur = message; + _etat = Etat.Erreur; + 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(); + _boucle = null; + + if (_module is null) + { + return; + } + + try + { + await _module.InvokeVoidAsync("arreter"); + } + catch (JSDisconnectedException) + { + } + } + + // Quitter l'écran doit libérer la caméra, pas seulement arrêter la boucle. + public async ValueTask DisposeAsync() + { + await ArreterCameraAsync(); + + if (_module is not null) + { + try + { + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + + _module = null; + } + } +} diff --git a/MaBibli.Client/MaBibli.Client.csproj b/MaBibli.Client/MaBibli.Client.csproj index 0bd012c..990f985 100644 --- a/MaBibli.Client/MaBibli.Client.csproj +++ b/MaBibli.Client/MaBibli.Client.csproj @@ -11,6 +11,8 @@ + + diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index c37479c..114ae9f 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -9,7 +9,7 @@ @if (_etape == Etape.Saisie) {

- Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard. + Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.

@@ -24,10 +24,20 @@ @onclick="ChercherAsync"> @(_chargement ? "Recherche…" : "Chercher") + @* La saisie manuelle reste le recours quand le code-barres est abîmé, absent, + ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@ + Saisir à la main
} +@if (_etape == Etape.Scan) +{ + +} + @if (_erreur is not null) { @@ -103,7 +113,7 @@ } @code { - private enum Etape { Saisie, Choix, Formulaire } + private enum Etape { Saisie, Scan, Choix, Formulaire } private Etape _etape = Etape.Saisie; private string _isbn = string.Empty; @@ -115,6 +125,22 @@ private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); + private void OuvrirScanner() + { + _erreur = null; + _avertissements = []; + _etape = Etape.Scan; + } + + // Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur + // ne retape jamais ce qui vient d'être scanné. + private async Task SurCodeDetecteAsync(string isbn) + { + _isbn = isbn; + _etape = Etape.Saisie; + await ChercherAsync(); + } + private async Task SurTouche(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn)) diff --git a/MaBibli.Client/Scan/BancEssaiScan.cs b/MaBibli.Client/Scan/BancEssaiScan.cs new file mode 100644 index 0000000..1ee0f0c --- /dev/null +++ b/MaBibli.Client/Scan/BancEssaiScan.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using Microsoft.JSInterop; + +namespace MaBibli.Client.Scan; + +/// +/// Mesure du coût réel du décodage dans le navigateur. +/// +/// CLAUDE.md ne dispose que de chiffres JIT x64 (0,53 ms/frame dans le pire cas) et d'une +/// extrapolation non vérifiée pour le WASM interprété (~5-10 ms/frame). Ce banc d'essai +/// donne le chiffre constaté, sur la machine de l'utilisateur, sans caméra ni code-barres. +/// +/// On mesure volontairement le pire cas — une frame de bruit sans code-barres — +/// car c'est l'échec de décodage qui coûte le plus cher, et la quasi-totalité des frames +/// d'une caméra sont dans ce cas. C'est donc lui qui gouverne le framerate atteignable. +/// +/// Depuis la console du navigateur : +/// await DotNet.invokeMethodAsync('MaBibli.Client', 'MesurerScan', 640, 288, 20) +/// +public static class BancEssaiScan +{ + [JSInvokable("MesurerScan")] + public static double MesurerPireCasMs(int largeur, int hauteur, int iterations) + { + var rgba = new byte[largeur * hauteur * 4]; + new Random(1234).NextBytes(rgba); + + // Un tour à blanc : la première exécution paie l'initialisation de ZXing, + // qui n'a rien à voir avec le coût par frame en régime établi. + IsbnScanner.TryDecode(rgba, largeur, hauteur); + + var depart = Stopwatch.GetTimestamp(); + for (var i = 0; i < iterations; i++) + { + IsbnScanner.TryDecode(rgba, largeur, hauteur); + } + + return Stopwatch.GetElapsedTime(depart).TotalMilliseconds / iterations; + } +} diff --git a/MaBibli.Client/Scan/IsbnScanner.cs b/MaBibli.Client/Scan/IsbnScanner.cs new file mode 100644 index 0000000..bbae909 --- /dev/null +++ b/MaBibli.Client/Scan/IsbnScanner.cs @@ -0,0 +1,53 @@ +using ZXing; +using ZXing.Common; + +namespace MaBibli.Client.Scan; + +/// +/// Décodage d'un code-barres EAN-13 / EAN-8 à partir d'un buffer de pixels RGBA. +/// +/// Décision actée dans CLAUDE.md : le décodage reste **entièrement en C#** (ZXing.Net), +/// le JavaScript ne sert qu'à fournir les pixels de la caméra. Cette classe ne dépend +/// donc ni du navigateur, ni de Blazor : elle est réutilisable telle quelle hors WASM +/// (app native, décodage d'une photo côté serveur, scan en masse). +/// +public static class IsbnScanner +{ + // ZXing.Net n'est pas thread-safe sur ce lecteur, mais le WASM est mono-thread + // et la boucle de scan est séquentielle : une instance partagée suffit et évite + // de reconstruire les hints à chaque frame. + private static readonly MultiFormatReader Reader = new() + { + Hints = new Dictionary + { + [DecodeHintType.POSSIBLE_FORMATS] = new List + { + BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, + }, + [DecodeHintType.TRY_HARDER] = true, + }, + }; + + /// + /// Tente de décoder un code-barres. Renvoie le texte brut du code, ou null + /// si la frame n'en contient aucun de lisible — cas très majoritaire en usage réel. + /// + /// Buffer brut issu de ctx.getImageData(...).data (4 octets par pixel). + public static string? TryDecode(byte[] rgba, int largeur, int hauteur) + { + ArgumentNullException.ThrowIfNull(rgba); + + if (largeur <= 0 || hauteur <= 0 || rgba.Length < largeur * hauteur * 4) + { + return null; + } + + // RGBLuminanceSource consomme directement le buffer du canvas : + // aucune bibliothèque d'image nécessaire (ni SkiaSharp, ni ImageSharp). + var source = new RGBLuminanceSource( + rgba, largeur, hauteur, RGBLuminanceSource.BitmapFormat.RGBA32); + + // La version .NET de ZXing renvoie null sur échec (pas d'exception). + return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text; + } +} diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index a86d6ee..2393ebd 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -449,3 +449,36 @@ body { flex: 0 0 auto; } } + +/* --- Scan du code-barres --- */ + +.scanner-cadre { + position: relative; + background: #000; + border-radius: 8px; + overflow: hidden; + aspect-ratio: 4 / 3; +} + +.scanner-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Le viseur matérialise la bande réellement décodée : viser ailleurs ne sert à rien. */ +.scanner-viseur { + position: absolute; + left: 5%; + right: 5%; + top: 27.5%; + height: 45%; + border: 2px solid rgba(255, 255, 255, 0.85); + border-radius: 6px; + box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.35); +} + +.scanner-mesure { + font-variant-numeric: tabular-nums; +} diff --git a/MaBibli.Client/wwwroot/js/scanner-camera.js b/MaBibli.Client/wwwroot/js/scanner-camera.js new file mode 100644 index 0000000..7c232a4 --- /dev/null +++ b/MaBibli.Client/wwwroot/js/scanner-camera.js @@ -0,0 +1,61 @@ +// Rôle unique de ce fichier : ouvrir la caméra et fournir des pixels à C#. +// AUCUN décodage ici — il est fait par ZXing.Net côté C# (décision actée dans CLAUDE.md). +// getUserMedia et canvas/getImageData n'ont pas d'équivalent C#, d'où ces quelques lignes. + +let flux = null; +let video = null; +let canvas = null; +let ctx = null; +let derniersPixels = null; + +// getUserMedia exige un contexte sécurisé : HTTPS, ou localhost. +// Une IP de réseau local (192.168.x.x) en http NE l'est PAS — piège classique en dev. +export function diagnostic() { + if (!window.isSecureContext) return 'contexte-non-securise'; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return 'api-absente'; + return 'ok'; +} + +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, + }); + video.srcObject = flux; + video.setAttribute('playsinline', ''); + await video.play(); + canvas = document.createElement('canvas'); + ctx = canvas.getContext('2d', { willReadFrequently: true }); +} + +// Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si +// la vidéo n'est pas encore prête. Les pixels se récupèrent ensuite via pixels(). +export function capturer(largeurMax, partHauteur) { + if (!video || !ctx || video.readyState < 2 || !video.videoWidth) return null; + const echelle = Math.min(1, largeurMax / video.videoWidth); + const l = Math.round(video.videoWidth * echelle); + const h = Math.round(video.videoHeight * echelle); + if (canvas.width !== l || canvas.height !== h) { canvas.width = l; canvas.height = h; } + ctx.drawImage(video, 0, 0, l, h); + // On ne renvoie que la bande centrale, celle que l'utilisateur vise : moins de pixels + // à transférer et à décoder, donc moins de travail par frame côté C#. + const hb = Math.max(1, Math.round(h * partHauteur)); + const y = Math.round((h - hb) / 2); + derniersPixels = new Uint8Array(ctx.getImageData(0, y, l, hb).data.buffer); + return [l, hb]; +} + +export function pixels() { + return derniersPixels; +} + +// Libérer la caméra : sinon le voyant reste allumé et la batterie se vide, +// ce que l'utilisateur perçoit — à raison — comme un dysfonctionnement. +export function arreter() { + if (flux) { flux.getTracks().forEach(t => t.stop()); flux = null; } + if (video) { video.srcObject = null; video = null; } + canvas = null; ctx = null; derniersPixels = null; +} diff --git a/MaBibli.Tests/IsbnScannerTests.cs b/MaBibli.Tests/IsbnScannerTests.cs new file mode 100644 index 0000000..1b824f1 --- /dev/null +++ b/MaBibli.Tests/IsbnScannerTests.cs @@ -0,0 +1,102 @@ +using MaBibli.Client.Scan; +using ZXing.Common; +using ZXing.OneD; + +namespace MaBibli.Tests; + +/// +/// Tests du décodage pur (aucune caméra, aucun navigateur) : on encode un EAN-13 avec +/// ZXing, on le convertit en buffer RGBA comme le ferait getImageData, et on +/// vérifie le trajet aller-retour. Aucune image externe n'est nécessaire. +/// +public class IsbnScannerTests +{ + // Les ISBN de vérification du projet (voir CLAUDE.md). + [Theory] + [InlineData("9782070612758")] // Le Petit Prince + [InlineData("9782253004226")] // Germinal + [InlineData("9782080704092")] // Le Horla + [InlineData("9780262033848")] // Introduction to Algorithms + public void Decode_un_EAN13_genere(string isbn) + { + var (rgba, largeur, hauteur) = ImageEan13(isbn); + + Assert.Equal(isbn, IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Decode_aussi_un_EAN8() + { + // Pas un ISBN, mais le format est dans les hints : le décodeur doit le lire, + // c'est la couche appelante qui écarte les codes non-ISBN. + var matrice = new EAN8Writer().encode("96385074", ZXing.BarcodeFormat.EAN_8, 260, 160); + var (rgba, largeur, hauteur) = VersRgba(matrice); + + Assert.Equal("96385074", IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Renvoie_null_sur_une_frame_sans_code_barres() + { + // Cas très majoritaire en usage réel : la caméra filme autre chose. + // ZXing doit rendre null, jamais lever d'exception. + const int largeur = 640, hauteur = 480; + var rgba = new byte[largeur * hauteur * 4]; + var alea = new Random(1234); + alea.NextBytes(rgba); + + Assert.Null(IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Renvoie_null_sur_une_image_uniforme() + { + var rgba = new byte[320 * 200 * 4]; + Array.Fill(rgba, (byte)255); + + Assert.Null(IsbnScanner.TryDecode(rgba, 320, 200)); + } + + [Theory] + [InlineData(0, 100)] + [InlineData(100, 0)] + [InlineData(-5, 100)] + public void Renvoie_null_sur_des_dimensions_absurdes(int largeur, int hauteur) + => Assert.Null(IsbnScanner.TryDecode(new byte[4000], largeur, hauteur)); + + [Fact] + public void Renvoie_null_si_le_buffer_est_trop_court() + { + // Une frame tronquée ne doit pas faire tomber la boucle de scan. + Assert.Null(IsbnScanner.TryDecode(new byte[100], 640, 480)); + } + + private static (byte[] Rgba, int Largeur, int Hauteur) ImageEan13(string isbn) + => VersRgba(new EAN13Writer().encode(isbn, ZXing.BarcodeFormat.EAN_13, 380, 160)); + + /// + /// Convertit une BitMatrix en buffer RGBA, exactement la forme que le canvas + /// du navigateur fournit à C# (4 octets par pixel, opaque). + /// + private static (byte[] Rgba, int Largeur, int Hauteur) VersRgba(BitMatrix matrice) + { + var largeur = matrice.Width; + var hauteur = matrice.Height; + var rgba = new byte[largeur * hauteur * 4]; + + for (var y = 0; y < hauteur; y++) + { + for (var x = 0; x < largeur; x++) + { + var valeur = matrice[x, y] ? (byte)0 : (byte)255; + var i = ((y * largeur) + x) * 4; + rgba[i] = valeur; + rgba[i + 1] = valeur; + rgba[i + 2] = valeur; + rgba[i + 3] = 255; + } + } + + return (rgba, largeur, hauteur); + } +} diff --git a/MaBibli.Tests/MaBibli.Tests.csproj b/MaBibli.Tests/MaBibli.Tests.csproj index 758bbc5..212a6dc 100644 --- a/MaBibli.Tests/MaBibli.Tests.csproj +++ b/MaBibli.Tests/MaBibli.Tests.csproj @@ -26,6 +26,9 @@ + + From 3a00591117e85ff2ec0b59b43e3e983502eb02a4 Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 02:10:58 +0200 Subject: [PATCH 2/2] Corriger deux blocages du scan trouves en navigateur, et consigner les mesures reelles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux defauts ont ete constates en executant reellement l'application, pas par relecture : 1. L'import du module JS partait en 404 en developpement. L'import map generee par Blazor reecrit « ./js/scanner-camera.js » vers un nom empreinte que l'API hote ne sert pas (UseStaticFiles ignore les points d'entree empreintes). Une chaine de requete sur le specifieur empeche la reecriture ; en publication rien n'est reecrit pour ce fichier, le comportement est donc identique dans les deux modes. 2. Les messages d'erreur camera etaient tous generiques. Une DOMException perd son « name » en traversant le pont JS vers C# : un refus de permission arrivait sous la forme « Permission denied undefined ». Le JS attrape donc l'erreur et renvoie un code de statut stable, que le C# traduit en message utile. CLAUDE.md est mis a jour avec les mesures reelles obtenues en WASM, qui manquaient : la performance de decodage (l'estimation de 5-10 ms/frame est confirmee, l'AOT reste inutile) et le surcout de payload, nettement superieur aux 192 Ko documentes une fois comptees les assemblies BCL que ZXing empeche le trimmer de retirer. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 42 +++++++++++++-- .../Composants/ScannerCodeBarres.razor | 54 ++++++++++--------- MaBibli.Client/wwwroot/js/scanner-camera.js | 33 +++++++++--- 3 files changed, 95 insertions(+), 34 deletions(-) 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