fix(shopfloor): jsQR loop — full-res frame + canvas blank-pixel check + last-result trace

271 attempts at 720x1280 with no detection means either downsampling
killed the finder patterns or drawImage is silently painting blank
pixels (a known iOS WebKit failure mode for some video-stream sources).

- Drop the 600px scaling cap. Feed the full native video frame to
  jsQR. Per-frame cost goes up but is still fine; the win is jsQR
  sees finder patterns at full sharpness.
- Add a one-time sanity check that walks the first ImageData buffer
  looking for a non-zero pixel. If everything is 0,0,0 the canvas is
  blank (drawImage failed) and we surface that as 'px:BLANK' in the
  status line — telling us instantly to switch decoder strategies
  rather than chasing tuning.
- Status line now also shows the last jsQR call outcome:
    r:found / r:no_code / r:empty / r:error: ...
  So we can confirm whether jsQR is even being invoked successfully.

Status format compacted to fit one line on phone:
  jsqr · f1234 a567 720x1280 rs4 px:ok r:no_code

Version: shopfloor 19.0.20 -> 19.0.21.
This commit is contained in:
gsinghpal
2026-04-25 13:46:47 -04:00
parent 040f1463b4
commit 8e3169e49b
2 changed files with 40 additions and 22 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.20.0.0', 'version': '19.0.21.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.',

View File

@@ -211,8 +211,10 @@ export class QrScanner extends Component {
let attempts = 0; let attempts = 0;
let lastDecode = 0; let lastDecode = 0;
let lastStatus = 0; let lastStatus = 0;
let lastResult = "—"; // "found" | "no_code" | "empty" | error msg
let firstNonZeroPixel = -1; // sanity check that drawImage works
const MIN_INTERVAL_MS = 100; const MIN_INTERVAL_MS = 100;
const STATUS_INTERVAL_MS = 400; const STATUS_INTERVAL_MS = 500;
const tick = (now) => { const tick = (now) => {
if (!this.decodeLoopActive || !this.state.open) return; if (!this.decodeLoopActive || !this.state.open) return;
frames++; frames++;
@@ -226,38 +228,54 @@ export class QrScanner extends Component {
try { try {
const w = v.videoWidth; const w = v.videoWidth;
const h = v.videoHeight; const h = v.videoHeight;
// Cap the working frame at 600px on the long side — // Use the native video resolution directly — no
// enough resolution for jsQR to find an ~33-module // downscaling. jsQR's runtime cost is acceptable
// QR, while keeping per-frame cost reasonable. // even at 1080p, and downsampling can blur the
const scale = Math.min(1, 600 / Math.max(w, h)); // finder patterns just enough to defeat detection
const cw = Math.round(w * scale); // when the QR is small in the frame.
const ch = Math.round(h * scale); if (this._canvas.width !== w) this._canvas.width = w;
if (this._canvas.width !== cw) this._canvas.width = cw; if (this._canvas.height !== h) this._canvas.height = h;
if (this._canvas.height !== ch) this._canvas.height = ch; this._ctx.drawImage(v, 0, 0, w, h);
this._ctx.drawImage(v, 0, 0, cw, ch); const imageData = this._ctx.getImageData(0, 0, w, h);
const imageData = this._ctx.getImageData(0, 0, cw, ch); if (firstNonZeroPixel < 0) {
// attemptBoth tries the image as-is AND inverted — // One-time sanity check: confirm drawImage is
// covers cases where a camera's auto-exposure makes // actually painting the video onto the canvas.
// the QR look light-on-dark to the decoder. // If every pixel is 0,0,0 we'd never decode
const code = window.jsQR(imageData.data, cw, ch, { // anything regardless of jsQR settings (this
// can happen with tainted canvases on some
// older WebKit builds).
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i] | imageData.data[i + 1] | imageData.data[i + 2]) {
firstNonZeroPixel = i;
break;
}
}
if (firstNonZeroPixel < 0) firstNonZeroPixel = -2;
}
const code = window.jsQR(imageData.data, w, h, {
inversionAttempts: "attemptBoth", inversionAttempts: "attemptBoth",
}); });
if (code && code.data) { if (code && code.data) {
lastResult = "found";
this._handleCode(code.data); this._handleCode(code.data);
return; return;
} }
lastResult = code ? "empty" : "no_code";
} catch (e) { } catch (e) {
this.state.statusLine = "Decoder: jsqr — error: " + lastResult = "error: " +
(e.message || String(e)).slice(0, 80); (e.message || String(e)).slice(0, 60);
} }
} }
if (now - lastStatus > STATUS_INTERVAL_MS) { if (now - lastStatus > STATUS_INTERVAL_MS) {
lastStatus = now; lastStatus = now;
this.state.statusLine = this.state.statusLine =
"Decoder: jsqr · frames " + frames + "jsqr · f" + frames +
" · attempts " + attempts + " a" + attempts +
" · video " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) + " " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
" rs" + v.readyState; " rs" + v.readyState +
" px:" + (firstNonZeroPixel === -2
? "BLANK" : firstNonZeroPixel < 0 ? "?" : "ok") +
" r:" + lastResult;
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
}; };