import { DropImageFetchError } from "./errors"; import { BarcodeDetector, type DetectedBarcode } from "barcode-detector"; import { eventOn } from "./util"; export interface QRCodeLocation { topLeftCorner: { x: number; y: number }; topRightCorner: { x: number; y: number }; bottomRightCorner: { x: number; y: number }; bottomLeftCorner: { x: number; y: number }; topLeftFinderPattern: Record; topRightFinderPattern: Record; bottomLeftFinderPattern: Record; } export interface QRCodeResult { content: string | null; location: QRCodeLocation | null; imageData: ImageData | null; binaryData: Uint8ClampedArray | null; } const adaptOldFormat = (detectedCodes: DetectedBarcode[]): QRCodeResult => { if (detectedCodes.length > 0) { const [firstCode] = detectedCodes; const [topLeftCorner, topRightCorner, bottomRightCorner, bottomLeftCorner] = firstCode.cornerPoints; return { content: firstCode.rawValue, location: { topLeftCorner, topRightCorner, bottomRightCorner, bottomLeftCorner, // not supported by native API: topLeftFinderPattern: {}, topRightFinderPattern: {}, bottomLeftFinderPattern: {}, }, imageData: null, binaryData: null, }; } else { return { content: null, location: null, imageData: null, binaryData: null, }; } }; interface ScanState { lastScanned: number; contentBefore: string | null; locationBefore: QRCodeLocation | null; binaryData: Uint8ClampedArray | null; } export interface KeepScanningOptions { detectHandler: (result: QRCodeResult) => void; locateHandler: (detectedCodes: DetectedBarcode[]) => void; minDelay: number; } /** * Continuously extracts frames from camera stream and tries to read * potentially pictured QR codes. */ export const keepScanning = ( videoElement: HTMLVideoElement, options: KeepScanningOptions ): void => { const barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] }); const { detectHandler, locateHandler, minDelay } = options; const processFrame = (state: ScanState) => async (timeNow: number): Promise => { if (videoElement.readyState > 1) { const { lastScanned, contentBefore, locationBefore } = state; if (timeNow - lastScanned >= minDelay) { const detectedCodes = await barcodeDetector.detect(videoElement); const { content, location, imageData, binaryData } = adaptOldFormat(detectedCodes); if (content !== null && content !== contentBefore) { detectHandler({ content, location, imageData, binaryData }); } if (location !== null || locationBefore !== null) { locateHandler(detectedCodes); } window.requestAnimationFrame( processFrame({ lastScanned: timeNow, contentBefore: content ?? contentBefore, locationBefore: location, binaryData: binaryData, }) ); } else { window.requestAnimationFrame(processFrame(state)); } } }; processFrame({ contentBefore: null, locationBefore: null, lastScanned: performance.now(), binaryData: null, })(performance.now()); }; const imageElementFromUrl = async (url: string): Promise => { if (url.startsWith("http") && url.includes(location.host) === false) { throw new DropImageFetchError(); } const image = document.createElement("img"); image.src = url; await eventOn(image, "load"); return image; }; export const processFile = async ( file: File | Blob | ImageBitmap | ImageData ): Promise => { const barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] }); const detectedCodes = await barcodeDetector.detect(file); return adaptOldFormat(detectedCodes); }; export const processUrl = async (url: string): Promise => { const barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] }); const image = await imageElementFromUrl(url); const detectedCodes = await barcodeDetector.detect(image); return adaptOldFormat(detectedCodes); };