Files
mabibli/MaBibli.Client/Composants/ScannerCodeBarres.razor
T
mathieuandClaude Opus 5 9cb3ca535d Reparer la publication de l API (index.html cassé)
Le publish de MaBibli.Api recopiait index.html tel quel, placeholders
compris, et l application publiee restait blanche.

Cause : dans le SDK .NET 10, la cible de publication
GenerateHtmlAssetPlaceholdersPublishStaticWebAssets lit la liste des
fichiers HTML dans @(_HtmlStaticWebAssets), item alimente uniquement par
la cible de *build*. Quand c est l API qui demande au client ses assets de
publication, la cible de build n a pas tourne : la liste est vide, aucun
asset HTML calcule n entre dans le manifeste, et c est le fichier source
qui est recopie par ComputeResolvedFilesToPublishList.

Correction : supprimer le besoin de reecriture plutot que la reparer.
WasmFingerprintAssets=false et pas de OverrideHtmlAssetPlaceholders (c est
lui qui, a true, forcait l empreinte de blazor.webassembly.js) ; index.html
reference des noms stables, sans import map ni preload placeholder. Le
contournement « ?m=1 » de l import du module scanner devient inutile.

Verifie : publish self-contained lance, scripts references en 200,
application chargee dans un navigateur (catalogue affiche, module JS du
scanner charge, aucun 404).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:42:58 +02:00

266 lines
9.2 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 }
// 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;
// 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;
}
}
}