fix(scan): speed up QR recognition and keep scanning

This commit is contained in:
Dave 2026-10-05 09:43:53 +02:00
parent f10020705c
commit c8da9daf94
5 changed files with 290 additions and 88 deletions

View file

@ -1,14 +1,73 @@
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ScanPage from "./page";
import * as qrDetect from "@/lib/scan/qr-detect";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("ScanPage", () => {
it("keeps the latest scanned note when an earlier lookup finishes late", async () => {
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }) },
});
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
let onScan: ((raw: string) => void) | undefined;
vi.spyOn(qrDetect, "scanVideoFrames").mockImplementation((_video, _canvas, onFound) => {
onScan = onFound;
return new Promise(() => {});
});
const pending = new Map<string, (response: Response) => void>();
vi.stubGlobal("fetch", (path: string) => new Promise<Response>((resolve) => {
pending.set(new URL(path, "http://localhost").searchParams.get("number")!, resolve);
}));
const resolved = (number: string) => new Response(JSON.stringify({
id: number,
delivery_note_number: number,
status: "draft",
customer_name: "Customer",
company_name: "Company",
customer_po: "",
delivery_date: "",
signed_copy: "",
item_count: 1,
}), { status: 200 });
render(<ScanPage />);
fireEvent.click(screen.getByRole("button", { name: "Start camera" }));
await waitFor(() => expect(onScan).toBeDefined());
act(() => onScan!("DN26100401"));
act(() => onScan!("DN26100402"));
expect(screen.getByRole("status")).toHaveTextContent("Delivery note recognised");
await act(async () => pending.get("DN26100402")!(resolved("DN26100402")));
expect(screen.getByTestId("scan-note")).toHaveTextContent("DN26100402");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
await act(async () => pending.get("DN26100401")!(resolved("DN26100401")));
expect(screen.getByTestId("scan-note")).toHaveTextContent("DN26100402");
expect(screen.getByTestId("scan-note")).not.toHaveTextContent("DN26100401");
});
it("requests a sharp rear-camera feed instead of relying on low-resolution defaults", async () => {
vi.stubGlobal("isSecureContext", true);
const getUserMedia = vi.fn().mockRejectedValue(new Error("Camera unavailable in test"));
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
render(<ScanPage />);
fireEvent.click(screen.getByRole("button", { name: "Start camera" }));
await waitFor(() => expect(screen.getByTestId("scan-toast")).toHaveTextContent("Camera unavailable in test"));
const constraints = getUserMedia.mock.calls[0][0];
expect(constraints.video.facingMode).toEqual({ ideal: "environment" });
expect(constraints.video.width.ideal).toBeGreaterThanOrEqual(1920);
expect(constraints.video.height.ideal).toBeGreaterThanOrEqual(1080);
expect(constraints.audio).toBe(false);
});
it("renders camera controls without a render loop and remains usable after typing", () => {
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal("navigator", {

View file

@ -133,11 +133,13 @@ export default function ScanPage() {
// Last code we accepted, and when — so holding the camera on one
// note for 3s does not fire the lookup over and over.
const lastCodeRef = React.useRef<{ code: string; at: number } | null>(null);
const lookupSequenceRef = React.useRef(0);
// Stop the camera on unmount. Leaving a getUserMedia stream running
// keeps the camera light on after the operator navigates away.
React.useEffect(
() => () => {
lookupSequenceRef.current++;
abortRef.current?.abort();
streamRef.current?.getTracks().forEach((t) => t.stop());
void wakeRef.current?.release();
@ -228,11 +230,13 @@ export default function ScanPage() {
const last = lastCodeRef.current;
if (last && last.code === number && now - last.at < DEBOUNCE_MS) return;
lastCodeRef.current = { code: number, at: now };
const sequence = ++lookupSequenceRef.current;
setBusy("lookup");
try {
const data = await api(
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
);
if (sequence !== lookupSequenceRef.current) return;
setNote(data as Resolved);
// The camera deliberately KEEPS RUNNING. The operator often has
// a stack of notes; scanning the next one should not need a
@ -241,11 +245,12 @@ export default function ScanPage() {
// the same paper must not re-fire, hence the debounce above.
flash("ok", `${number} loaded`);
} catch (err) {
if (sequence !== lookupSequenceRef.current) return;
const msg = err instanceof Error ? err.message : "Lookup failed";
setNote(null);
flash("err", msg);
} finally {
setBusy(null);
if (sequence === lookupSequenceRef.current) setBusy(null);
}
},
[api, flash],
@ -259,7 +264,12 @@ export default function ScanPage() {
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
},
audio: false,
});
streamRef.current = stream;
@ -287,7 +297,7 @@ export default function ScanPage() {
const msg = err instanceof Error ? err.message : String(err);
flash("err", `Camera failed to start: ${msg}`);
}
}, [support, flash, lookup]);
}, [support, flash, lookup, requestWakeLock]);
const runAction = React.useCallback(
async (key: string, fn: () => Promise<void>) => {
@ -374,6 +384,13 @@ export default function ScanPage() {
</div>
<canvas ref={canvasRef} className="hidden" />
{busy === "lookup" ? (
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Delivery note recognised — loading details…
</p>
) : null}
{/* ---- the note card, or the scanner controls ---- */}
{note ? (
<section className="flex flex-col gap-4" data-testid="scan-note">

View file

@ -7,7 +7,6 @@ import {
decodeQrFromCanvas,
detectScannerSupport,
MAX_SCAN_EDGE,
SCAN_REGIONS,
} from "./qr-detect";
/**
@ -246,16 +245,3 @@ describe("scan resolution floor", () => {
expect(pxPerModule).toBeGreaterThan(2.5);
});
});
describe("SCAN_REGIONS", () => {
it("checks the bottom band every frame and the whole frame periodically", () => {
// The bottom band is where the delivery-note QR actually lives, so
// it is the cheap common case. The full frame is the safety net
// for a tilted or rotated page and must not run every frame.
const [band, whole] = SCAN_REGIONS;
expect(band.everyNthFrame).toBe(1);
expect(band.region.sy).toBeGreaterThan(0.4);
expect(whole.everyNthFrame).toBeGreaterThan(1);
expect(whole.region).toEqual({ sx: 0, sy: 0, sw: 1, sh: 1 });
});
});

View file

@ -152,48 +152,38 @@ export async function decodeQrFromCanvas(
return result?.data ?? null;
}
/**
* Longest edge, in pixels, that a region is rasterised to before
* decoding.
*
* This number is the whole ballgame and it was badly wrong at 640.
* The printed QR is 28pt on a 210mm page — 4.7% of the page width. So
* a 640px-wide frame looking at a full A4 sheet renders that QR at
* ~30px total, which is about 1 pixel per module. Measured against
* the real rendered page, jsQR cannot read that at ANY crop:
*
* cap px/module whole frame bottom-45% ROI
* 640 1.04 MISS MISS
* 1280 2.08 miss miss
* 1600 2.60 HIT 69ms HIT 28ms
* 1920 3.12 HIT 98ms HIT 39ms
* 2560 4.16 HIT 171ms HIT 39ms
*
* So a scanner that downscaled to 640 could simply never read its own
* delivery notes at a normal framing. Above ~2.5px per module it is
* reliable. 1920 keeps ~3.1px/module with room for a smaller print,
* and caps the worst-case decode cost on a high-res sensor.
*/
// Keep enough detail in the whole-frame fallback to read older, smaller
// printed QRs. Cropping the aiming box is cheaper than shrinking the
// entire page and losing the QR's individual modules.
export const MAX_SCAN_EDGE = 1920;
const GUIDE_SCAN_EDGE = 768;
const SCAN_INTERVAL_MS = 100;
/**
* Where to look, cheapest first.
*
* The delivery-note QR sits in the bottom band of the page, so the
* lower 45% of the frame finds it at roughly a third of the cost of
* the full frame (39ms vs 98ms at 1920px). It is an ACCELERATOR, not a
* restriction — a tilted or upside-down page puts the code outside it,
* so the full frame is scanned on a slower rotation as the safety net.
*/
export type ScanRegion = { sx: number; sy: number; sw: number; sh: number };
export const SCAN_REGIONS: Array<{ region: ScanRegion; everyNthFrame: number }> = [
// Bottom 45% — where the code actually is, scanned every frame.
{ region: { sx: 0, sy: 0.55, sw: 1, sh: 0.45 }, everyNthFrame: 1 },
// Whole frame, every 6th frame, so a tilted page still resolves
// without paying the full-frame cost on every tick.
{ region: { sx: 0, sy: 0, sw: 1, sh: 1 }, everyNthFrame: 6 },
];
function scanRegions(width: number, height: number) {
// The preview uses object-cover in a square/4:3 viewfinder. A central
// square based on the shorter source edge stays inside the visible
// aiming box for both portrait and landscape camera streams.
const side = Math.round(Math.min(width, height) * 0.64);
return [
{
region: {
sx: (width - side) / (2 * width),
sy: (height - side) / (2 * height),
sw: side / width,
sh: side / height,
},
everyNthFrame: 1,
maxEdge: GUIDE_SCAN_EDGE,
},
{
region: { sx: 0, sy: 0, sw: 1, sh: 1 },
everyNthFrame: 3,
maxEdge: MAX_SCAN_EDGE,
},
];
}
/** Rasterise one region of the current video frame into the canvas. */
function drawRegion(
@ -201,12 +191,13 @@ function drawRegion(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
region: ScanRegion,
maxEdge: number,
): { w: number; h: number } {
const srcW = video.videoWidth;
const srcH = video.videoHeight;
const cropW = Math.max(1, Math.round(srcW * region.sw));
const cropH = Math.max(1, Math.round(srcH * region.sh));
const scale = Math.min(1, MAX_SCAN_EDGE / cropW);
const scale = Math.min(1, maxEdge / Math.max(cropW, cropH));
const w = Math.max(1, Math.round(cropW * scale));
const h = Math.max(1, Math.round(cropH * scale));
if (canvas.width !== w) canvas.width = w;
@ -226,18 +217,11 @@ function drawRegion(
}
/**
* Run the detector over a video stream until it finds something.
*
* Driven by requestAnimationFrame rather than a timer, which matters
* for three reasons:
*
* - rAF fires immediately after a painted frame, so the frame handed
* to the decoder is a fresh one. A `setInterval` samples on its own
* schedule and routinely decodes the same stale frame twice.
* - There is no 120 ms quantum. The old loop put a floor of up to
* 120 ms on every detection, before any decode time.
* - `readyState` stalls recover on the next rAF instead of waiting
* out a timer tick.
* Keep scanning until aborted, reporting each new code immediately.
* Sample at most ten times a second so decoding does not monopolise
* the phone's main thread or prevent the camera preview from updating.
* Start in the aiming box; periodically check the whole image on a
* miss so off-centre and older small printed QRs are still recognised.
*
* Rejects nothing on `signal` — it resolves, so the caller can `await`
* a clean stop. A single bad frame must never kill the loop.
@ -256,6 +240,9 @@ export function scanVideoFrames(
let stopped = false;
let frame = 0;
let rafId = 0;
let lastScanAt = -Infinity;
let lastRaw: string | null = null;
let lastSeenAt = -Infinity;
// A decode is async (the native detector is), so guard against
// overlapping ticks stacking up on a slow device.
let busy = false;
@ -270,34 +257,41 @@ export function scanVideoFrames(
const onAbort = () => finish();
signal.addEventListener("abort", onAbort, { once: true });
const tick = async () => {
const tick = async (timestamp: number) => {
if (stopped) return;
rafId = requestAnimationFrame(() => void tick());
rafId = requestAnimationFrame((time) => void tick(time));
if (busy || signal.aborted) return;
if (video.readyState < 2 || !video.videoWidth) return;
if (timestamp - lastScanAt < SCAN_INTERVAL_MS) return;
lastScanAt = timestamp;
for (const entry of SCAN_REGIONS) {
if (stopped) return;
if (entry.everyNthFrame > 1 && frame % entry.everyNthFrame !== 0) continue;
busy = true;
try {
const { w, h } = drawRegion(video, canvas, ctx, entry.region);
const raw = await decodeQrFromCanvas(canvas, ctx, w, h);
if (raw && !stopped) {
onFound(raw);
finish();
return;
busy = true;
try {
for (const entry of scanRegions(video.videoWidth, video.videoHeight)) {
if (stopped) return;
if (frame % entry.everyNthFrame !== 0) continue;
try {
const { w, h } = drawRegion(video, canvas, ctx, entry.region, entry.maxEdge);
const raw = await decodeQrFromCanvas(canvas, ctx, w, h);
if (raw && !stopped) {
// Holding one paper still must not flood the API/toasts.
// A different note is accepted on the very next sample.
if (raw !== lastRaw || timestamp - lastSeenAt > 1000) onFound(raw);
lastRaw = raw;
lastSeenAt = timestamp;
return;
}
} catch {
/* a single bad frame must not kill the loop */
}
} catch {
/* a single bad frame must not kill the loop */
} finally {
busy = false;
}
} finally {
busy = false;
frame++;
}
frame++;
};
rafId = requestAnimationFrame(() => void tick());
rafId = requestAnimationFrame((time) => void tick(time));
if (signal.aborted) finish();
});
}

View file

@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildQrMatrix } from "@/lib/pdf/qr";
import { scanVideoFrames } from "./qr-detect";
afterEach(() => vi.unstubAllGlobals());
// A real QR raster passed through the production decoder. Only the
// camera/canvas transport and animation clock are replaced.
function scanner(width = 1920, height = 1080) {
let scene = new Uint8ClampedArray(width * height * 4).fill(255);
let decodedPixels = 0;
let frameId = 0;
let timestamp = 0;
const frames = new Map<number, FrameRequestCallback>();
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.set(++frameId, callback);
return frameId;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
const canvas = { width: 0, height: 0 } as HTMLCanvasElement;
let drawn = new Uint8ClampedArray();
const ctx = {
drawImage(_video: unknown, sx: number, sy: number, sw: number, sh: number, _dx: number, _dy: number, dw: number, dh: number) {
drawn = new Uint8ClampedArray(dw * dh * 4);
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const from = (Math.min(height - 1, Math.floor(sy + y * sh / dh)) * width + Math.min(width - 1, Math.floor(sx + x * sw / dw))) * 4;
drawn.set(scene.subarray(from, from + 4), (y * dw + x) * 4);
}
}
},
getImageData() {
decodedPixels += canvas.width * canvas.height;
return { data: drawn, width: canvas.width, height: canvas.height };
},
} as unknown as CanvasRenderingContext2D;
canvas.getContext = (() => ctx) as unknown as typeof canvas.getContext;
const video = { readyState: 2, videoWidth: width, videoHeight: height } as HTMLVideoElement;
const found: string[] = [];
const controller = new AbortController();
const done = scanVideoFrames(video, canvas, (raw) => found.push(raw), controller.signal);
return {
found,
get decodedPixels() { return decodedPixels; },
show(number: string, x = Math.round(width / 2 - 58), y = Math.round(height / 2 - 58)) {
scene = new Uint8ClampedArray(width * height * 4).fill(255);
const qr = buildQrMatrix(number);
for (let i = 0; i < qr.modules.length; i++) {
if (!qr.modules[i]) continue;
const mx = (i % qr.size) + qr.quietZone;
const my = Math.floor(i / qr.size) + qr.quietZone;
for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) {
const offset = ((y + my * 4 + dy) * width + x + mx * 4 + dx) * 4;
scene[offset] = scene[offset + 1] = scene[offset + 2] = 0;
}
}
},
async frame(elapsed = 100) {
timestamp += elapsed;
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(timestamp));
// Flush the native-detector probe and asynchronous decode continuation.
for (let i = 0; i < 8; i++) await Promise.resolve();
},
async stop() { controller.abort(); await done; },
};
}
describe("live QR scanning", () => {
it("leaves time for the preview instead of decoding every display refresh", async () => {
const camera = scanner();
try {
camera.show("DN26100401");
await camera.frame();
const initialPixels = camera.decodedPixels;
for (let i = 0; i < 4; i++) await camera.frame(16);
expect(camera.decodedPixels).toBe(initialPixels);
camera.show("DN26100402");
await camera.frame();
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
} finally { await camera.stop(); }
});
it("stops processing camera frames when aborted", async () => {
const camera = scanner();
camera.show("DN26100401");
await camera.stop();
await camera.frame();
expect(camera.found).toEqual([]);
expect(camera.decodedPixels).toBe(0);
});
it("reads the QR inside the aiming box without decoding the whole HD frame", async () => {
const camera = scanner();
try {
camera.show("DN26100401");
await camera.frame();
expect(camera.found).toEqual(["DN26100401"]);
expect(camera.decodedPixels).toBeLessThan(600_000);
} finally { await camera.stop(); }
});
it("keeps reading subsequent notes without restarting the camera", async () => {
const camera = scanner();
try {
camera.show("DN26100401");
await camera.frame();
camera.show("DN26100402");
await camera.frame();
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
} finally { await camera.stop(); }
});
it("does not repeatedly report the paper held in front of the camera", async () => {
const camera = scanner();
try {
camera.show("DN26100401");
for (let i = 0; i < 5; i++) await camera.frame();
expect(camera.found).toEqual(["DN26100401"]);
camera.show("DN26100402");
await camera.frame();
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
} finally { await camera.stop(); }
});
it("also finds a QR outside the aiming box", async () => {
const camera = scanner();
try {
camera.show("DN26100401", 100, 80);
for (let i = 0; i < 3; i++) await camera.frame();
expect(camera.found).toEqual(["DN26100401"]);
} finally { await camera.stop(); }
});
it("reads a QR in the centre of a portrait camera feed", async () => {
const camera = scanner(1080, 1920);
try {
camera.show("DN26100401");
await camera.frame();
expect(camera.found).toEqual(["DN26100401"]);
expect(camera.decodedPixels).toBeLessThan(600_000);
} finally { await camera.stop(); }
});
});