Corriger deux blocages du scan trouves en navigateur, et consigner les mesures reelles

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>
This commit is contained in:
mathieu
2026-08-18 02:10:58 +02:00
co-authored by Claude Opus 5
parent f7ad9582f6
commit 3a00591117
3 changed files with 95 additions and 34 deletions
+39 -3
View File
@@ -60,18 +60,54 @@ Le décodage EAN-13 se fait en **C# avec [ZXing.Net](https://www.nuget.org/packa
|---|---|
| Décodage EAN-13 propre (380×160) | 0,04 ms/frame |
| **Pire cas** : frame 640×480 bruitée sans code-barres (échec) | 0,53 ms/frame |
| Surcoût du payload PWA | +192 Ko (brotli) |
| Poids de l'assembly `zxing.wasm` seul | +192 Ko (brotli) |
Le pire cas est le chiffre qui gouverne le framerate : la majorité des frames caméra ne contiennent pas de code-barres lisible, et c'est l'échec de décodage qui coûte le plus cher.
⚠️ Ces chiffres sont mesurés en **JIT x64 natif**. En Blazor WASM le code est *interprété* par défaut : compter un facteur ~10-20×, soit ~5-10 ms/frame — largement suffisant pour scanner à 10-15 fps. Activer `<RunAOTCompilation>true</RunAOTCompilation>` ramène ça à 1-2 ms, au prix d'un build nettement plus lent.
### Mesures en WASM réel — faites le 2026-08-18, phase scan
L'estimation « ~5-10 ms/frame en interprété » ci-dessus n'était qu'une extrapolation. Elle a
été **vérifiée dans un vrai navigateur** (Chromium 148, x86_64 de bureau), sur le publish
`Release` du client, en appelant le décodeur depuis la console via `BancEssaiScan` :
| Configuration | Pire cas 640×288 (bande visée) | Pire cas 640×480 |
|---|---|---|
| Publish `Release`, interprété | **4,4 6,4 ms/frame** | 6,7 7,8 ms/frame |
| Build `Debug`, interprété | 17,7 ms/frame | 28,7 ms/frame |
**L'estimation était bonne** : le mode interprété tient largement les 10-15 fps visés (une
frame toutes les 80 ms n'utilise que ~6 % du budget). **Aucune raison d'activer l'AOT**
il reste à confirmer sur un téléphone, sensiblement plus lent qu'un x86_64 de bureau.
⚠️ Ne jamais juger la fluidité sur un build `Debug` : il est **4 à 5× plus lent** que le
`Release`, de quoi conclure à tort qu'il faut l'AOT.
### ⚠️ Le surcoût de payload réel est bien supérieur à 192 Ko
Mesuré par différence entre deux publish `Release` complets (somme brotli de `_framework`) :
**+351 Ko** au total, dont ~4,7 Ko de code applicatif. Le coût imputable à ZXing.Net est donc
d'environ **+346 Ko brotli**, soit **1,8× le poids de son propre assembly**. Le surplus vient
des assemblies BCL que le trimmer ne peut plus retirer :
| Assembly | Delta brotli |
|---|---|
| `zxing.wasm` | +192 495 o |
| `System.Text.RegularExpressions` | +91 237 o (de 7 Ko à 98 Ko : ZXing utilise Regex, tout le moteur reste) |
| `System.Runtime.Numerics` | +30 701 o (nouveau) |
| `System.Private.CoreLib` | +17 586 o |
| divers (`Threading`, `Collections`, `InteropServices`…) | ~14 Ko |
Ne pas reprendre « +192 Ko » comme coût du scan : c'est le poids de l'assembly, pas celui
de la fonctionnalité.
### Pièges à connaître
- **Le JS interop ne disparaît pas.** `getUserMedia` et `<canvas>`/`getImageData` sont des API web sans équivalent C#. Prévoir ~30 lignes de JS maison dont le seul rôle est de pousser un `byte[]` vers C#. Toute la logique de décodage reste en C#.
- **Ne pas perdre de temps à essayer de réduire la taille via un reader ciblé.** Remplacer `MultiFormatReader` par `EAN13Reader` pour aider le trimmer **ne change rien** : mesuré à 192 495 octets à l'octet près dans les deux cas. ZXing.Net n'est pas trim-friendly.
- `RGBLuminanceSource` accepte directement le buffer RGBA du canvas (`BitmapFormat.RGBA32`) — **aucune bibliothèque d'image nécessaire** (pas de SkiaSharp ni ImageSharp).
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr).
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). Corollaire vérifié : tester le scan depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera toujours** — ce n'est pas un contexte sécurisé. Le composant détecte ce cas et le dit explicitement.
- **Une `DOMException` perd son `name` en traversant le pont JS→C#** : `getUserMedia` refusé remonte en C# sous la forme « Permission denied undefined », sans `NotAllowedError`. Or c'est ce nom qui distingue « permission refusée » de « pas de caméra » de « caméra occupée ». Le JS doit donc **attraper l'erreur et renvoyer un code de statut** ; parser le message côté C# ne marche pas.
- **L'import du module JS doit porter une chaîne de requête** (`./js/scanner-camera.js?m=1`). En développement, l'import map généré par Blazor réécrit le chemin 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 part en 404 et le scan ne démarre jamais. La chaîne de requête empêche cette réécriture ; en publication rien n'est réécrit pour ce fichier, donc le comportement est identique.
### Squelette validé
@@ -67,6 +67,14 @@
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;
@@ -96,7 +104,7 @@
try
{
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/scanner-camera.js");
_module = await JS.InvokeAsync<IJSObjectReference>("import", CheminModule);
var diagnostic = await _module.InvokeAsync<string>("diagnostic");
if (diagnostic != "ok")
@@ -114,29 +122,32 @@
return;
}
await _module.InvokeVoidAsync("demarrer", _video);
// 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 (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.");
@@ -216,13 +227,6 @@
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();
+27 -6
View File
@@ -16,19 +16,40 @@ export function diagnostic() {
return 'ok';
}
// Renvoie un code de statut plutôt que de laisser remonter l'exception : une DOMException
// traversée par le pont JS→C# perd son `name` (mesuré : C# ne reçoit que « Permission denied
// undefined »), or c'est précisément ce nom qui dit à l'utilisateur ce qui lui manque.
export async function demarrer(element) {
video = element;
// facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi.
// "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale.
flux = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
try {
// facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi.
// "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale.
flux = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
} catch (e) {
switch (e.name) {
case 'NotAllowedError':
case 'SecurityError':
return 'permission-refusee';
case 'NotFoundError':
case 'DevicesNotFoundError':
case 'OverconstrainedError':
return 'aucune-camera';
case 'NotReadableError':
case 'TrackStartError':
return 'camera-occupee';
default:
return 'erreur:' + (e.name || 'Error') + ' — ' + (e.message || '');
}
}
video.srcObject = flux;
video.setAttribute('playsinline', '');
await video.play();
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d', { willReadFrequently: true });
return 'ok';
}
// Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si