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:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.19.0.0',
|
'version': '19.0.20.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
|
|||||||
@@ -112,8 +112,16 @@ export class QrScanner extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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({
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: { ideal: "environment" } },
|
video: {
|
||||||
|
facingMode: { ideal: "environment" },
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
},
|
||||||
audio: false,
|
audio: false,
|
||||||
});
|
});
|
||||||
// Wait one paint tick so the t-ref resolves to the <video>
|
// 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
|
* synchronously. jsQR is ~250KB but pure JS, so it works on every
|
||||||
* browser that gives us getUserMedia.
|
* browser that gives us getUserMedia.
|
||||||
*
|
*
|
||||||
* Throttled to one decode per ~120ms to stay under 10% CPU on
|
* Throttled to one decode per ~100ms to stay responsive without
|
||||||
* mid-range Android phones.
|
* 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() {
|
_jsQRDecodeLoop() {
|
||||||
this.decodeLoopActive = true;
|
this.decodeLoopActive = true;
|
||||||
const v = this.videoRef.el;
|
const v = this.videoRef.el;
|
||||||
if (!v) return;
|
if (!v) {
|
||||||
|
this.state.statusLine = "Decoder: jsqr — video element missing";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this._canvas) {
|
if (!this._canvas) {
|
||||||
this._canvas = document.createElement("canvas");
|
this._canvas = document.createElement("canvas");
|
||||||
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
|
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
|
||||||
}
|
}
|
||||||
|
let frames = 0;
|
||||||
|
let attempts = 0;
|
||||||
let lastDecode = 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) => {
|
const tick = (now) => {
|
||||||
if (!this.decodeLoopActive || !this.state.open) return;
|
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;
|
lastDecode = now;
|
||||||
|
attempts++;
|
||||||
try {
|
try {
|
||||||
const w = v.videoWidth;
|
const w = v.videoWidth;
|
||||||
const h = v.videoHeight;
|
const h = v.videoHeight;
|
||||||
if (w && h) {
|
// Cap the working frame at 600px on the long side —
|
||||||
// Cap the working frame at 480px on the long side
|
// enough resolution for jsQR to find an ~33-module
|
||||||
// — jsQR doesn't need full HD and the cost scales
|
// QR, while keeping per-frame cost reasonable.
|
||||||
// with pixel count.
|
const scale = Math.min(1, 600 / Math.max(w, h));
|
||||||
const scale = Math.min(1, 480 / Math.max(w, h));
|
const cw = Math.round(w * scale);
|
||||||
const cw = Math.round(w * scale);
|
const ch = Math.round(h * scale);
|
||||||
const ch = Math.round(h * scale);
|
if (this._canvas.width !== cw) this._canvas.width = cw;
|
||||||
if (this._canvas.width !== cw) this._canvas.width = cw;
|
if (this._canvas.height !== ch) this._canvas.height = ch;
|
||||||
if (this._canvas.height !== ch) this._canvas.height = ch;
|
this._ctx.drawImage(v, 0, 0, cw, ch);
|
||||||
this._ctx.drawImage(v, 0, 0, cw, ch);
|
const imageData = this._ctx.getImageData(0, 0, cw, ch);
|
||||||
const imageData = this._ctx.getImageData(0, 0, cw, ch);
|
// attemptBoth tries the image as-is AND inverted —
|
||||||
const code = window.jsQR(imageData.data, cw, ch, {
|
// covers cases where a camera's auto-exposure makes
|
||||||
inversionAttempts: "dontInvert",
|
// the QR look light-on-dark to the decoder.
|
||||||
});
|
const code = window.jsQR(imageData.data, cw, ch, {
|
||||||
if (code && code.data) {
|
inversionAttempts: "attemptBoth",
|
||||||
this._handleCode(code.data);
|
});
|
||||||
return;
|
if (code && code.data) {
|
||||||
}
|
this._handleCode(code.data);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
|
|||||||
Reference in New Issue
Block a user