fix(shopfloor): jsQR decode loop diagnostics + attemptBoth + 720p stream

The previous loop was running but never finding the QR. Three changes
to make it actually decode AND make any future failure visible:

1. inversionAttempts: 'dontInvert' -> 'attemptBoth'
   Some camera exposures wash out the QR enough that jsQR sees it
   inverted. attemptBoth tries both polarities per frame; the cost is
   ~2x decode time but jsQR is fast enough that scan stays interactive.

2. getUserMedia now requests 1280x720 (with downgrade allowed). The
   default 640x480 only gives ~5 pixels per QR module on a typical
   phone-to-sticker distance — borderline for jsQR. 720p doubles
   that and makes detection near-instant.

3. The status line is now LIVE, updating every 400ms with:
     Decoder: jsqr · frames N · attempts M · video WxH rsN
   So when an operator says "scan does nothing" we can immediately see
   whether the loop is running, whether the camera is feeding frames,
   whether jsQR is being called, and at what resolution. No Web
   Inspector required.

Version: shopfloor 19.0.19 -> 19.0.20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 13:42:49 -04:00
parent 9fe7855fc3
commit 040f1463b4
2 changed files with 61 additions and 26 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.19.0.0',
'version': '19.0.20.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -112,8 +112,16 @@ export class QrScanner extends Component {
return;
}
try {
// Request a 1280x720 rear-camera stream when possible. The
// browser will downgrade if the device can't deliver it.
// Higher resolution gives jsQR more pixels per QR module
// and dramatically improves decode rate on phones.
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
// Wait one paint tick so the t-ref resolves to the <video>
@@ -181,49 +189,76 @@ export class QrScanner extends Component {
* synchronously. jsQR is ~250KB but pure JS, so it works on every
* browser that gives us getUserMedia.
*
* Throttled to one decode per ~120ms to stay under 10% CPU on
* mid-range Android phones.
* Throttled to one decode per ~100ms to stay responsive without
* pegging mid-range phones. Updates a live status line so the
* operator can see exactly what the loop is doing — frames seen,
* decode attempts, video resolution. Critical for diagnosing
* "scan does nothing" reports without round-tripping through
* Safari Web Inspector.
*/
_jsQRDecodeLoop() {
this.decodeLoopActive = true;
const v = this.videoRef.el;
if (!v) return;
if (!v) {
this.state.statusLine = "Decoder: jsqr — video element missing";
return;
}
if (!this._canvas) {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
}
let frames = 0;
let attempts = 0;
let lastDecode = 0;
const MIN_INTERVAL_MS = 120;
let lastStatus = 0;
const MIN_INTERVAL_MS = 100;
const STATUS_INTERVAL_MS = 400;
const tick = (now) => {
if (!this.decodeLoopActive || !this.state.open) return;
if (v.readyState >= 2 && (now - lastDecode) >= MIN_INTERVAL_MS) {
frames++;
if (
v.readyState >= 2 &&
v.videoWidth && v.videoHeight &&
(now - lastDecode) >= MIN_INTERVAL_MS
) {
lastDecode = now;
attempts++;
try {
const w = v.videoWidth;
const h = v.videoHeight;
if (w && h) {
// Cap the working frame at 480px on the long side
// — jsQR doesn't need full HD and the cost scales
// with pixel count.
const scale = Math.min(1, 480 / Math.max(w, h));
const cw = Math.round(w * scale);
const ch = Math.round(h * scale);
if (this._canvas.width !== cw) this._canvas.width = cw;
if (this._canvas.height !== ch) this._canvas.height = ch;
this._ctx.drawImage(v, 0, 0, cw, ch);
const imageData = this._ctx.getImageData(0, 0, cw, ch);
const code = window.jsQR(imageData.data, cw, ch, {
inversionAttempts: "dontInvert",
});
if (code && code.data) {
this._handleCode(code.data);
return;
}
// Cap the working frame at 600px on the long side —
// enough resolution for jsQR to find an ~33-module
// QR, while keeping per-frame cost reasonable.
const scale = Math.min(1, 600 / Math.max(w, h));
const cw = Math.round(w * scale);
const ch = Math.round(h * scale);
if (this._canvas.width !== cw) this._canvas.width = cw;
if (this._canvas.height !== ch) this._canvas.height = ch;
this._ctx.drawImage(v, 0, 0, cw, ch);
const imageData = this._ctx.getImageData(0, 0, cw, ch);
// attemptBoth tries the image as-is AND inverted —
// covers cases where a camera's auto-exposure makes
// the QR look light-on-dark to the decoder.
const code = window.jsQR(imageData.data, cw, ch, {
inversionAttempts: "attemptBoth",
});
if (code && code.data) {
this._handleCode(code.data);
return;
}
} catch (e) {
// Same as native: swallow per-frame errors and try again.
this.state.statusLine = "Decoder: jsqr — error: " +
(e.message || String(e)).slice(0, 80);
}
}
if (now - lastStatus > STATUS_INTERVAL_MS) {
lastStatus = now;
this.state.statusLine =
"Decoder: jsqr · frames " + frames +
" · attempts " + attempts +
" · video " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
" rs" + v.readyState;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);