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>
264 lines
9.1 KiB
Plaintext
264 lines
9.1 KiB
Plaintext
@*
|
||
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;
|
||
}
|
||
}
|
||
}
|