Files
mabibli/MaBibli.Client/Composants/ScannerCodeBarres.razor
T
mathieuandClaude Opus 5 278d6579f7 Basculer le décodage des codes-barres de ZXing.Net vers zbar
L'ISBN 9782846391009 n'était pas décodé par l'application, alors qu'il
l'était par une application d'essai utilisant zbar, sur le même livre et
le même appareil.

⚠️ Le raisonnement qui avait écarté cette piste était faux : CLAUDE.md
concluait que « le décodeur n'y est pour rien » à partir de mesures de
VITESSE (4-6 ms/frame). La vitesse ne dit rien du taux de réussite.

Le décodage quitte donc le C# pour zbar compilé en WebAssembly, dont les
assets viennent du paquet ZBar.Blazor.

Ce que la bascule apporte, au-delà du code lu :
- plus aucun pixel ne traverse le pont JS→C# (un byte[] par frame avant),
  seule la valeur décodée le fait ;
- l'image ENTIÈRE est analysée, à 960 px, là où l'ancienne version
  recadrait sur la bande centrale à 640 px pour alléger ce transfert —
  c'était la seconde cause possible des codes non lus, elle disparaît ;
- zbar.wasm est chargé À LA DEMANDE, à la première ouverture du scanner.

⚠️ Le composant ZBarCamera du paquet n'est PAS utilisé : il ouvre la
caméra lui-même et avale les erreurs. On y perdrait les messages qui
distinguent permission refusée, absence de caméra, caméra occupée et
contexte non sécurisé, ainsi que facingMode environment (caméra arrière)
et l'indication de résolution. Seuls zbar.js et zbar.wasm sont empruntés.

Poids, comparaison de deux publish Release complets (somme brotli) :
  _framework  3 281 558 → 2 978 700 o   (−296 Kio au démarrage)
  total       3 281 558 → 3 125 541 o   (−152 Kio)
L'écart vient de la traîne que ZXing imposait au trimmer :
System.Text.RegularExpressions retombe de 98 374 à 7 137 o, et
System.Runtime.Numerics disparaît.

⚠️ Coût assumé : six tests de décodage disparaissent avec IsbnScanner,
le décodeur n'étant plus en C#. Le décodage a été vérifié dans le
navigateur sur un EAN-13 rendu en canvas — 9782846391009 ressort bien en
ZBAR_EAN13 — mais cela reste une vérification, pas un garde-fou.
À confirmer avec le livre en main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:51:56 +02:00

260 lines
9.2 KiB
Plaintext

@*
Scan du code-barres ISBN par la caméra.
Le décodage se fait dans wwwroot/js/scanner-camera.js, par zbar compilé en WebAssembly.
⚠️ Il était en C# (ZXing.Net) jusqu'au 2026-08-19 : voir CLAUDE.md, ZXing.Net ne lisait pas
des codes-barres que zbar lit sur le même livre. Ce composant ne voit plus passer un seul
pixel — seulement la valeur décodée.
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.Shared.Isbn
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="scanner">
@if (_etat == Etat.Erreur)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
else
{
<div class="scanner-cadre">
<video @ref="_video" class="scanner-video" muted playsinline autoplay></video>
<div class="scanner-viseur" aria-hidden="true"></div>
</div>
<p class="message-discret">
@if (_etat == Etat.Demarrage)
{
<text>Ouverture de la caméra…</text>
}
else
{
<text>Visez le code-barres au dos du livre, bien à plat et bien éclairé.</text>
}
</p>
@if (_avertissementFormat is not null)
{
<p class="message-avertissement">@_avertissementFormat</p>
}
@if (_framesDecodees > 0)
{
@* Mesure sur l'appareil de l'utilisateur. ⚠️ Elle porte maintenant sur la capture,
le décodage zbar ET l'aller-retour d'interopérabilité : elle n'est pas comparable
aux chiffres ZXing.Net de CLAUDE.md, qui ne mesuraient que le décodage C#. *@
<p class="message-discret scanner-mesure">
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
</p>
}
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-discret" @onclick="AnnulerAsync">Annuler</button>
</div>
</div>
@code {
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
[Parameter] public EventCallback<string> OnCodeDetecte { get; set; }
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
[Parameter] public EventCallback OnAnnuler { get; set; }
private enum Etat { Demarrage, Actif, Erreur }
// Chemin nu : il n'y a plus d'import map depuis que les empreintes WASM sont désactivées
// (voir MaBibli.Client.csproj), donc plus de réécriture vers un nom empreinté que l'hôte
// ne servirait pas. Le contournement « ?m=1 » qui neutralisait cette réécriture n'a plus
// lieu d'être. Vérifié en développement et en publication.
private const string CheminModule = "./js/scanner-camera.js";
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
private const int IntervalleMs = 80;
// L'image est réduite avant décodage. Elle ne traverse plus le pont JS→C# — c'est zbar,
// côté JS, qui la lit — mais 960 px de large restent un bon compromis entre le détail
// nécessaire à un EAN-13 dense et le temps de décodage.
//
// ⚠️ Plus large qu'avec ZXing.Net (640 px), et surtout l'image ENTIÈRE est analysée : le
// recadrage sur la bande centrale n'existait que pour alléger le transfert de pixels.
private const int LargeurMax = 960;
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<IJSObjectReference>("import", CheminModule);
var diagnostic = await _module.InvokeAsync<string>("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;
}
// 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<string>("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 (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 depart = Stopwatch.GetTimestamp();
var code = await _module.InvokeAsync<string?>("decoder", LargeurMax);
_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 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;
}
}
}