diff --git a/src/app/scan/page.test.tsx b/src/app/scan/page.test.tsx index 383939e..c979b56 100644 --- a/src/app/scan/page.test.tsx +++ b/src/app/scan/page.test.tsx @@ -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 void>(); + vi.stubGlobal("fetch", (path: string) => new Promise((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(); + 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(); + + 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", { diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx index 6d85013..7977883 100644 --- a/src/app/scan/page.tsx +++ b/src/app/scan/page.tsx @@ -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) => { @@ -374,6 +384,13 @@ export default function ScanPage() { + {busy === "lookup" ? ( +

+ + Delivery note recognised — loading details… +

+ ) : null} + {/* ---- the note card, or the scanner controls ---- */} {note ? (
diff --git a/src/lib/scan/qr-detect.test.ts b/src/lib/scan/qr-detect.test.ts index d261f71..af60037 100644 --- a/src/lib/scan/qr-detect.test.ts +++ b/src/lib/scan/qr-detect.test.ts @@ -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 }); - }); -}); diff --git a/src/lib/scan/qr-detect.ts b/src/lib/scan/qr-detect.ts index d77350e..f379ee5 100644 --- a/src/lib/scan/qr-detect.ts +++ b/src/lib/scan/qr-detect.ts @@ -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(); }); } diff --git a/src/lib/scan/scan-video.test.ts b/src/lib/scan/scan-video.test.ts new file mode 100644 index 0000000..f142840 --- /dev/null +++ b/src/lib/scan/scan-video.test.ts @@ -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(); + 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(); } + }); +});