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 <noreply@anthropic.com>
268 lines
9.4 KiB
Plaintext
268 lines
9.4 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 }
|
||
|
||
// 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;
|
||
|
||
// 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", 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 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 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;
|
||
}
|
||
}
|
||
}
|