Files
mabibli/MaBibli.Client/Composants/ScannerCodeBarres.razor
T
mathieuandClaude Opus 5 f7ad9582f6 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 <noreply@anthropic.com>
2026-08-17 23:32:05 +02:00

264 lines
9.1 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@*
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
<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 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. *@
<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 }
// ~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<IJSObjectReference>("import", "./js/scanner-camera.js");
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;
}
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<int[]?>("capturer", LargeurMax, PartHauteur);
if (dimensions is null)
{
continue; // vidéo pas encore prête
}
var pixels = await _module.InvokeAsync<byte[]>("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;
}
}
}