fix(shopfloor): use ZXing's actual API (decodeFromVideoElementContinuously)
The previous patch called reader.decodeFromCanvas which doesn't exist in @zxing/library 0.21.3. Real methods (verified by grep on the vendored bundle) are: decodeFromVideoElement(el) -- one-shot decodeFromVideoElementContinuously(el, cb) -- continuous loop Switched to the continuous variant. ZXing manages its own per-frame timing internally — we just register the (result, err) callback and React to result.getText() on hits. NotFoundException = no QR this frame, which we silently ignore. Also fixed the related video-play race: ZXing internally registers a 'playing' event listener on the video and then calls play() itself. If we await v.play() ourselves first, the 'playing' event fires BEFORE ZXing attaches its listener and ZXing then waits forever for an event that already happened. Fix: for the zxing path we set attributes + srcObject but do NOT call play(). ZXing's playVideoOnLoadAsync handles play -> playing -> decode in the right order. The native and jsQR paths still pre-play because their loops poll the video themselves. Cleanup: _stopCamera now calls reader.reset() to tear down ZXing's internal state cleanly when the modal closes. Version: shopfloor 19.0.22 -> 19.0.23. 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.22.0.0',
|
'version': '19.0.23.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.',
|
||||||
|
|||||||
@@ -144,20 +144,37 @@ export class QrScanner extends Component {
|
|||||||
// Wait one paint tick so the t-ref resolves to the <video>
|
// Wait one paint tick so the t-ref resolves to the <video>
|
||||||
await new Promise((r) => requestAnimationFrame(r));
|
await new Promise((r) => requestAnimationFrame(r));
|
||||||
const v = this.videoRef.el;
|
const v = this.videoRef.el;
|
||||||
if (v) {
|
if (!v) {
|
||||||
v.srcObject = this.stream;
|
this.state.error = "Video element not mounted";
|
||||||
// iOS Safari requires both attributes AND the explicit
|
return;
|
||||||
// play() call; without them the stream stays black.
|
|
||||||
v.setAttribute("playsinline", "true");
|
|
||||||
v.muted = true;
|
|
||||||
await v.play();
|
|
||||||
}
|
}
|
||||||
|
// iOS Safari requires playsinline + muted on the element
|
||||||
|
// BEFORE it has a source, otherwise the stream stays black
|
||||||
|
// and play() rejects with "user interaction required".
|
||||||
|
v.setAttribute("playsinline", "true");
|
||||||
|
v.setAttribute("muted", "true");
|
||||||
|
v.muted = true;
|
||||||
|
v.srcObject = this.stream;
|
||||||
|
|
||||||
if (this.state.decoder === "zxing") {
|
if (this.state.decoder === "zxing") {
|
||||||
|
// CRITICAL: do NOT call v.play() here. ZXing's
|
||||||
|
// decodeFromVideoElementContinuously registers a
|
||||||
|
// "playing" event listener and then calls play()
|
||||||
|
// itself; if play() has already happened, the
|
||||||
|
// "playing" event fired before the listener attached
|
||||||
|
// and ZXing waits forever. Leaving the video paused
|
||||||
|
// here lets ZXing drive the play -> playing -> decode
|
||||||
|
// sequence cleanly.
|
||||||
this._zxingDecodeLoop();
|
this._zxingDecodeLoop();
|
||||||
} else if (this.state.decoder === "native") {
|
} else {
|
||||||
this._nativeDecodeLoop();
|
// Native BarcodeDetector / jsQR loops both poll the
|
||||||
} else if (this.state.decoder === "jsqr") {
|
// video themselves, so they need it actively playing.
|
||||||
this._jsQRDecodeLoop();
|
await v.play();
|
||||||
|
if (this.state.decoder === "native") {
|
||||||
|
this._nativeDecodeLoop();
|
||||||
|
} else if (this.state.decoder === "jsqr") {
|
||||||
|
this._jsQRDecodeLoop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.state.error = "Couldn't access camera: " + (e.message || e);
|
this.state.error = "Couldn't access camera: " + (e.message || e);
|
||||||
@@ -166,6 +183,16 @@ export class QrScanner extends Component {
|
|||||||
|
|
||||||
_stopCamera() {
|
_stopCamera() {
|
||||||
this.decodeLoopActive = false;
|
this.decodeLoopActive = false;
|
||||||
|
// Stop ZXing's internal decode loop if it's running. reset()
|
||||||
|
// is the documented teardown for BrowserMultiFormatReader.
|
||||||
|
if (this._zxingReader) {
|
||||||
|
try {
|
||||||
|
this._zxingReader.reset();
|
||||||
|
} catch (e) {
|
||||||
|
// Some versions throw on double-reset; safe to ignore.
|
||||||
|
}
|
||||||
|
this._zxingReader = null;
|
||||||
|
}
|
||||||
if (this.stream) {
|
if (this.stream) {
|
||||||
this.stream.getTracks().forEach((t) => t.stop());
|
this.stream.getTracks().forEach((t) => t.stop());
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
@@ -203,17 +230,24 @@ export class QrScanner extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode loop using ZXing-js. ZXing is the gold standard for
|
* Continuous QR decode using ZXing-js. We feed our existing
|
||||||
* pure-JS QR decoding — it's the same algorithm family the iOS
|
* <video> element (already wired to the getUserMedia stream)
|
||||||
* Camera app uses internally, with HybridBinarizer + perspective
|
* straight into ZXing's continuous reader, which manages its
|
||||||
* transform that recover from skew, glare, and motion blur that
|
* own per-frame timing and decode pipeline (HybridBinarizer +
|
||||||
* jsQR rejects.
|
* perspective transform) — the same algorithm family the iOS
|
||||||
|
* Camera app uses internally.
|
||||||
*
|
*
|
||||||
* We use the lower-level decodeFromCanvas API rather than ZXing's
|
* The vendored bundle exposes these instance methods on
|
||||||
* built-in continuous video reader so we keep ownership of the
|
* ZXing.BrowserMultiFormatReader:
|
||||||
* <video> element, the getUserMedia stream, and the modal UI.
|
* - decodeFromVideoElement(el) -> one-shot
|
||||||
* Status line tracks attempts and last result so any stalls are
|
* - decodeFromVideoElementContinuously(el, cb) -> loop
|
||||||
* immediately visible.
|
* The continuous form callbacks `(result, err)` per frame:
|
||||||
|
* `result` truthy on hit, `err` is usually a NotFoundException
|
||||||
|
* (no code in this frame) which we ignore.
|
||||||
|
*
|
||||||
|
* Cleanup: `reader.reset()` stops the loop and releases internal
|
||||||
|
* state. We call it from _stopCamera() so closing the modal is
|
||||||
|
* clean.
|
||||||
*/
|
*/
|
||||||
_zxingDecodeLoop() {
|
_zxingDecodeLoop() {
|
||||||
this.decodeLoopActive = true;
|
this.decodeLoopActive = true;
|
||||||
@@ -222,79 +256,70 @@ export class QrScanner extends Component {
|
|||||||
this.state.statusLine = "Decoder: zxing — video element missing";
|
this.state.statusLine = "Decoder: zxing — video element missing";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this._canvas) {
|
const Z = window.ZXing;
|
||||||
this._canvas = document.createElement("canvas");
|
const reader = new Z.BrowserMultiFormatReader();
|
||||||
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
|
this._zxingReader = reader;
|
||||||
}
|
|
||||||
// Constrain ZXing to QR codes only — skips all the other
|
// Restrict to QR codes — skips Code128 / EAN / PDF417 probes
|
||||||
// 1D / 2D format probes (Code 128, EAN, PDF417, etc.) that
|
// we don't need, halves per-frame work.
|
||||||
// double the per-frame cost without helping us.
|
|
||||||
const reader = new window.ZXing.BrowserMultiFormatReader();
|
|
||||||
try {
|
try {
|
||||||
const hints = new Map();
|
const hints = new Map();
|
||||||
// DecodeHintType.POSSIBLE_FORMATS = 2 in zxing-js
|
// DecodeHintType.POSSIBLE_FORMATS == 2 in the zxing-js enum.
|
||||||
hints.set(2, [window.ZXing.BarcodeFormat.QR_CODE]);
|
const formatsKey = (Z.DecodeHintType && Z.DecodeHintType.POSSIBLE_FORMATS) || 2;
|
||||||
reader.hints = hints;
|
const qrFormat = (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE);
|
||||||
|
if (qrFormat !== undefined) {
|
||||||
|
hints.set(formatsKey, [qrFormat]);
|
||||||
|
reader.hints = hints;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Older ZXing versions don't expose hints this way; fine,
|
// Hint API differs across versions — fine, decode all formats.
|
||||||
// just decode all formats.
|
|
||||||
}
|
}
|
||||||
let frames = 0;
|
|
||||||
let attempts = 0;
|
// Live status — ZXing manages its own timing internally so we
|
||||||
let lastDecode = 0;
|
// count callbacks instead of rAF ticks. Hits is what matters.
|
||||||
|
let callbacks = 0;
|
||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
let lastResult = "—";
|
let lastResult = "—";
|
||||||
const MIN_INTERVAL_MS = 100;
|
const refreshStatus = () => {
|
||||||
const STATUS_INTERVAL_MS = 500;
|
const now = performance.now();
|
||||||
const tick = (now) => {
|
if (now - lastStatus > 400) {
|
||||||
if (!this.decodeLoopActive || !this.state.open) return;
|
|
||||||
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 (this._canvas.width !== w) this._canvas.width = w;
|
|
||||||
if (this._canvas.height !== h) this._canvas.height = h;
|
|
||||||
this._ctx.drawImage(v, 0, 0, w, h);
|
|
||||||
// ZXing's decodeFromCanvas returns a Result on
|
|
||||||
// success and throws NotFoundException on miss.
|
|
||||||
const result = reader.decodeFromCanvas(this._canvas);
|
|
||||||
const text = result && (result.getText ? result.getText() : result.text);
|
|
||||||
if (text) {
|
|
||||||
lastResult = "found";
|
|
||||||
this._handleCode(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastResult = "empty";
|
|
||||||
} catch (e) {
|
|
||||||
// NotFoundException = no QR in frame — common,
|
|
||||||
// not an error. Anything else we surface.
|
|
||||||
const name = e && (e.name || (e.constructor && e.constructor.name));
|
|
||||||
if (name === "NotFoundException" || name === "NotFoundException2") {
|
|
||||||
lastResult = "no_code";
|
|
||||||
} else {
|
|
||||||
lastResult = "err: " + ((e && e.message) || String(e)).slice(0, 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (now - lastStatus > STATUS_INTERVAL_MS) {
|
|
||||||
lastStatus = now;
|
lastStatus = now;
|
||||||
this.state.statusLine =
|
this.state.statusLine =
|
||||||
"zxing · f" + frames +
|
"zxing · cb" + callbacks +
|
||||||
" a" + attempts +
|
|
||||||
" " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
|
" " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
|
||||||
" rs" + v.readyState +
|
" rs" + v.readyState +
|
||||||
" r:" + lastResult;
|
" r:" + lastResult;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
};
|
||||||
requestAnimationFrame(tick);
|
|
||||||
|
try {
|
||||||
|
reader.decodeFromVideoElementContinuously(v, (result, err) => {
|
||||||
|
callbacks++;
|
||||||
|
if (result) {
|
||||||
|
lastResult = "found";
|
||||||
|
refreshStatus();
|
||||||
|
const text = result.getText ? result.getText() : result.text;
|
||||||
|
this._handleCode(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
const name = err.name || (err.constructor && err.constructor.name) || "";
|
||||||
|
if (name.indexOf("NotFound") >= 0) {
|
||||||
|
lastResult = "no_code";
|
||||||
|
} else if (name.indexOf("Checksum") >= 0 || name.indexOf("Format") >= 0) {
|
||||||
|
// Found something QR-shaped but couldn't read it
|
||||||
|
// (blurry / damaged) — keep trying next frame.
|
||||||
|
lastResult = "partial";
|
||||||
|
} else {
|
||||||
|
lastResult = "err:" + (err.message || name).slice(0, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatus();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.state.statusLine = "zxing init failed: " +
|
||||||
|
((e && e.message) || String(e)).slice(0, 80);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user