/* eslint-disable @typescript-eslint/no-explicit-any */ "use client" import * as React from "react" import cvModule from "@techstark/opencv-js" import { CameraIcon, CameraOffIcon, CheckIcon, ChevronDownIcon, DownloadIcon, FileImageIcon, FlashlightIcon, FlashlightOffIcon, ImageIcon, ImagesIcon, LoaderCircleIcon, RefreshCwIcon, RotateCwIcon, ScanLineIcon, ShieldAlertIcon, Trash2Icon, UploadIcon, XIcon, } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" type Point = { x: number; y: number } export type DocumentQuad = [Point, Point, Point, Point] export type DocumentScannerStatus = | "idle" | "starting" | "ready" | "detecting" | "stable" | "capturing" | "processing" | "review" | "error" export type DocumentPermissionState = "prompt" | "granted" | "denied" | "unsupported" export type DocumentProcessingMode = "color" | "grayscale" | "black-white" export type DocumentCameraDevice = { deviceId: string label: string groupId: string } export type DocumentDetection = { corners: DocumentQuad areaRatio: number confidence: number stableFrames: number stable: boolean } export type DocumentScanResult = { id: string blob: Blob dataUrl: string width: number height: number sourceWidth: number sourceHeight: number corners: DocumentQuad detected: boolean confidence: number processingMode: DocumentProcessingMode cameraId?: string createdAt: number } export type DocumentScannerSnapshot = { status: DocumentScannerStatus permission: DocumentPermissionState cameraActive: boolean cameraId?: string cameras: DocumentCameraDevice[] torchSupported: boolean torchEnabled: boolean detection: DocumentDetection | null scans: DocumentScanResult[] selectedScanId: string | null error: Error | null } export type DocumentScannerHandle = { start: () => Promise stop: () => void scan: () => Promise scanFile: (file: File | Blob) => Promise listCameras: () => Promise switchCamera: (deviceId: string) => Promise toggleTorch: (enabled?: boolean) => Promise removeScan: (id: string) => void clearScans: () => void selectScan: (id: string | null) => void downloadScan: (id?: string) => void getSnapshot: () => DocumentScannerSnapshot video: HTMLVideoElement | null } export type DocumentScannerLabels = { start?: string stop?: string capture?: string captureAgain?: string importImage?: string cameras?: string selectCamera?: string torchOn?: string torchOff?: string autoCapture?: string autoCaptureReady?: string loadingEngine?: string requestingPermission?: string permissionDenied?: string insecureContext?: string cameraUnavailable?: string cameraNotReady?: string detecting?: string holdSteady?: string documentAligned?: string processing?: string documentNotFound?: string review?: string scans?: string remove?: string clear?: string download?: string noScans?: string color?: string grayscale?: string blackWhite?: string retry?: string } export type DocumentScannerStateContent = { idle?: React.ReactNode starting?: React.ReactNode permissionDenied?: React.ReactNode | ((error: Error | null, retry: () => void) => React.ReactNode) error?: React.ReactNode | ((error: Error, retry: () => void) => React.ReactNode) emptyReview?: React.ReactNode } export type DocumentScannerToolbarContext = DocumentScannerSnapshot & { processingMode: DocumentProcessingMode autoCapture: boolean actions: Pick< DocumentScannerHandle, | "start" | "stop" | "scan" | "listCameras" | "switchCamera" | "toggleTorch" | "removeScan" | "clearScans" | "selectScan" | "downloadScan" > & { openFilePicker: () => void } } export type DocumentScannerProps = Omit, "onError"> & { autoStart?: boolean facingMode?: "user" | "environment" cameraId?: string defaultCameraId?: string onCameraIdChange?: (cameraId: string | undefined) => void preferredWidth?: number preferredHeight?: number preferredFrameRate?: number outputType?: "image/jpeg" | "image/png" | "image/webp" outputQuality?: number maxOutputWidth?: number maxOutputHeight?: number processingMode?: DocumentProcessingMode defaultProcessingMode?: DocumentProcessingMode onProcessingModeChange?: (mode: DocumentProcessingMode) => void blackWhiteThreshold?: number requireDocument?: boolean minDocumentAreaRatio?: number minDetectionConfidence?: number detectionIntervalMs?: number stabilityFrames?: number stabilityTolerance?: number autoCapture?: boolean autoCaptureDelayMs?: number stopAfterCapture?: boolean scans?: DocumentScanResult[] defaultScans?: DocumentScanResult[] onScansChange?: (scans: DocumentScanResult[]) => void maxScans?: number selectedScanId?: string | null defaultSelectedScanId?: string | null onSelectedScanIdChange?: (id: string | null) => void showToolbar?: boolean showReview?: boolean showCameraSelector?: boolean allowTorch?: boolean allowFileImport?: boolean fileAccept?: string labels?: DocumentScannerLabels stateContent?: DocumentScannerStateContent renderToolbar?: (context: DocumentScannerToolbarContext) => React.ReactNode renderReview?: (context: DocumentScannerToolbarContext) => React.ReactNode beforePreview?: React.ReactNode afterPreview?: React.ReactNode overlay?: React.ReactNode onScan?: (result: DocumentScanResult) => void onDetectionChange?: (detection: DocumentDetection | null) => void onStatusChange?: (status: DocumentScannerStatus) => void onPermissionChange?: (permission: DocumentPermissionState) => void onDevicesChange?: (devices: DocumentCameraDevice[]) => void onCameraChange?: (active: boolean) => void onTorchChange?: (enabled: boolean) => void onError?: (error: Error) => void videoClassName?: string previewClassName?: string toolbarClassName?: string reviewClassName?: string } type OpenCvRuntime = Record type DetectionCandidate = { corners: DocumentQuad; areaRatio: number; confidence: number } let openCvPromise: Promise | null = null async function getOpenCv() { if (openCvPromise) return openCvPromise openCvPromise = (async () => { const candidate = await Promise.resolve(cvModule as unknown as Promise) as OpenCvRuntime if (candidate.Mat) return candidate await new Promise((resolve, reject) => { const timeout = window.setTimeout( () => reject(new Error("OpenCV runtime initialization timed out")), 20000 ) candidate.onRuntimeInitialized = () => { window.clearTimeout(timeout) resolve() } }) return candidate })().catch((error) => { openCvPromise = null throw error }) return openCvPromise } function useLatest(value: T) { const ref = React.useRef(value) ref.current = value return ref } function useControllableState({ value, defaultValue, onChange, }: { value: T | undefined defaultValue: T onChange?: (value: T) => void }) { const [internalValue, setInternalValue] = React.useState(defaultValue) const controlled = value !== undefined const currentValue = controlled ? value : internalValue const setValue = React.useCallback( (nextValue: T) => { if (!controlled) setInternalValue(nextValue) onChange?.(nextValue) }, [controlled, onChange] ) return [currentValue, setValue] as const } function createError(cause: unknown, fallback: string) { return cause instanceof Error ? cause : new Error(fallback) } function createId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID() return `scan-${Date.now()}-${Math.random().toString(36).slice(2)}` } function distance(a: Point, b: Point) { return Math.hypot(a.x - b.x, a.y - b.y) } function orderQuad(points: Point[]): DocumentQuad { if (points.length !== 4) throw new Error("A document contour must contain four points") const bySum = [...points].sort((a, b) => a.x + a.y - (b.x + b.y)) const byDiff = [...points].sort((a, b) => a.y - a.x - (b.y - b.x)) return [bySum[0], byDiff[0], bySum[3], byDiff[3]] } function fullFrameQuad(width: number, height: number): DocumentQuad { return [ { x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }, ] } function normalizeQuad(corners: DocumentQuad, width: number, height: number): DocumentQuad { return corners.map((point) => ({ x: point.x / Math.max(width, 1), y: point.y / Math.max(height, 1), })) as DocumentQuad } function quadDelta(previous: DocumentQuad, next: DocumentQuad) { return previous.reduce((total, point, index) => total + distance(point, next[index]), 0) / 4 } function calculateConfidence(corners: DocumentQuad, width: number, height: number, areaRatio: number) { const normalized = normalizeQuad(corners, width, height) const top = distance(normalized[0], normalized[1]) const bottom = distance(normalized[3], normalized[2]) const left = distance(normalized[0], normalized[3]) const right = distance(normalized[1], normalized[2]) const symmetry = 1 - Math.min(1, (Math.abs(top - bottom) + Math.abs(left - right)) / 2) const centered = 1 - Math.min( 1, Math.abs((normalized[0].x + normalized[1].x + normalized[2].x + normalized[3].x) / 4 - 0.5) + Math.abs((normalized[0].y + normalized[1].y + normalized[2].y + normalized[3].y) / 4 - 0.5) ) return Math.max(0, Math.min(1, areaRatio * 0.6 + symmetry * 0.25 + centered * 0.15)) } function findDocument(cv: OpenCvRuntime, source: any, minArea: number): DetectionCandidate | null { const gray = new cv.Mat() const blurred = new cv.Mat() const edges = new cv.Mat() const contours = new cv.MatVector() const hierarchy = new cv.Mat() let best: { area: number; points: Point[] } | null = null try { cv.cvtColor(source, gray, cv.COLOR_RGBA2GRAY) cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT) cv.Canny(blurred, edges, 55, 175) cv.findContours(edges, contours, hierarchy, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE) for (let index = 0; index < contours.size(); index += 1) { const contour = contours.get(index) const approximation = new cv.Mat() try { const perimeter = cv.arcLength(contour, true) cv.approxPolyDP(contour, approximation, 0.02 * perimeter, true) const area = Math.abs(cv.contourArea(approximation, false)) if (approximation.rows === 4 && cv.isContourConvex(approximation) && area >= minArea && (!best || area > best.area)) { const values = approximation.data32S const points: Point[] = [] for (let offset = 0; offset < values.length; offset += 2) { points.push({ x: values[offset], y: values[offset + 1] }) } best = { area, points } } } finally { approximation.delete() contour.delete() } } } finally { gray.delete() blurred.delete() edges.delete() contours.delete() hierarchy.delete() } if (!best) return null const corners = orderQuad(best.points) const areaRatio = best.area / Math.max(source.cols * source.rows, 1) return { corners, areaRatio, confidence: calculateConfidence(corners, source.cols, source.rows, areaRatio), } } function warpDocument(cv: OpenCvRuntime, source: any, corners: DocumentQuad) { const [topLeft, topRight, bottomRight, bottomLeft] = corners const width = Math.max( 1, Math.round(Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight))) ) const height = Math.max( 1, Math.round(Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight))) ) const sourcePoints = cv.matFromArray(4, 1, cv.CV_32FC2, [ topLeft.x, topLeft.y, topRight.x, topRight.y, bottomRight.x, bottomRight.y, bottomLeft.x, bottomLeft.y, ]) const destinationPoints = cv.matFromArray(4, 1, cv.CV_32FC2, [ 0, 0, width - 1, 0, width - 1, height - 1, 0, height - 1, ]) const transform = cv.getPerspectiveTransform(sourcePoints, destinationPoints) const output = new cv.Mat() try { cv.warpPerspective( source, output, transform, new cv.Size(width, height), cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar() ) return { output, width, height } } finally { sourcePoints.delete() destinationPoints.delete() transform.delete() } } function processOutput(cv: OpenCvRuntime, source: any, mode: DocumentProcessingMode, threshold: number) { if (mode === "color") return source const output = new cv.Mat() cv.cvtColor(source, output, cv.COLOR_RGBA2GRAY) if (mode === "black-white") { cv.threshold(output, output, threshold, 255, cv.THRESH_BINARY) } return output } function fitOutputDimensions(width: number, height: number, maxWidth?: number, maxHeight?: number) { const widthRatio = maxWidth ? maxWidth / width : 1 const heightRatio = maxHeight ? maxHeight / height : 1 const ratio = Math.min(1, widthRatio, heightRatio) return { width: Math.max(1, Math.round(width * ratio)), height: Math.max(1, Math.round(height * ratio)), } } async function canvasToBlob( canvas: HTMLCanvasElement, type: DocumentScannerProps["outputType"], quality: number ) { return new Promise((resolve, reject) => { canvas.toBlob( (value) => value ? resolve(value) : reject(new Error("Scanned image could not be encoded")), type, quality ) }) } function resolveStateContent( content: React.ReactNode | ((...args: T) => React.ReactNode) | undefined, fallback: React.ReactNode, ...args: T ) { if (typeof content === "function") return content(...args) return content ?? fallback } function DefaultIdleState({ labels, start }: { labels?: DocumentScannerLabels; start: () => void }) { return (

Camera is stopped

Start the camera or import an existing image.

) } function DefaultPermissionState({ error, labels, retry, }: { error: Error | null labels?: DocumentScannerLabels retry: () => void }) { return (

{labels?.permissionDenied ?? "Camera permission is unavailable"}

{error?.message ?? "Allow camera access in the browser or import an image instead."}

) } const DocumentScanner = React.forwardRef( function DocumentScanner( { autoStart = true, facingMode = "environment", cameraId, defaultCameraId, onCameraIdChange, preferredWidth = 1920, preferredHeight = 1080, preferredFrameRate = 30, outputType = "image/jpeg", outputQuality = 0.92, maxOutputWidth = 2480, maxOutputHeight = 3508, processingMode, defaultProcessingMode = "color", onProcessingModeChange, blackWhiteThreshold = 155, requireDocument = false, minDocumentAreaRatio = 0.12, minDetectionConfidence = 0.5, detectionIntervalMs = 240, stabilityFrames = 4, stabilityTolerance = 0.025, autoCapture = false, autoCaptureDelayMs = 700, stopAfterCapture = false, scans, defaultScans = [], onScansChange, maxScans = 20, selectedScanId, defaultSelectedScanId = null, onSelectedScanIdChange, showToolbar = true, showReview = true, showCameraSelector = true, allowTorch = true, allowFileImport = true, fileAccept = "image/*", labels, stateContent, renderToolbar, renderReview, beforePreview, afterPreview, overlay, onScan, onDetectionChange, onStatusChange, onPermissionChange, onDevicesChange, onCameraChange, onTorchChange, onError, className, videoClassName, previewClassName, toolbarClassName, reviewClassName, ...props }, forwardedRef ) { const rootRef = React.useRef(null) const videoRef = React.useRef(null) const sourceCanvasRef = React.useRef(null) const outputCanvasRef = React.useRef(null) const fileInputRef = React.useRef(null) const streamRef = React.useRef(null) const detectionTimerRef = React.useRef(null) const autoCaptureTimerRef = React.useRef(null) const previousDetectionRef = React.useRef(null) const stableFramesRef = React.useRef(0) const scanInProgressRef = React.useRef(false) const callbacksRef = useLatest({ onScan, onDetectionChange, onStatusChange, onPermissionChange, onDevicesChange, onCameraChange, onTorchChange, onError, }) const [currentCameraId, setCurrentCameraId] = useControllableState({ value: cameraId, defaultValue: defaultCameraId, onChange: onCameraIdChange, }) const [currentProcessingMode, setCurrentProcessingMode] = useControllableState({ value: processingMode, defaultValue: defaultProcessingMode, onChange: onProcessingModeChange, }) const [currentScans, setCurrentScans] = useControllableState({ value: scans, defaultValue: defaultScans, onChange: onScansChange, }) const [currentSelectedScanId, setCurrentSelectedScanId] = useControllableState({ value: selectedScanId, defaultValue: defaultSelectedScanId, onChange: onSelectedScanIdChange, }) const [status, setStatusState] = React.useState("idle") const [permission, setPermissionState] = React.useState( typeof navigator === "undefined" || !navigator.mediaDevices ? "unsupported" : "prompt" ) const [cameraActive, setCameraActive] = React.useState(false) const [cameras, setCameras] = React.useState([]) const [torchSupported, setTorchSupported] = React.useState(false) const [torchEnabled, setTorchEnabled] = React.useState(false) const [detection, setDetectionState] = React.useState(null) const [error, setError] = React.useState(null) const [engineLoading, setEngineLoading] = React.useState(false) const setStatus = React.useCallback((nextStatus: DocumentScannerStatus) => { setStatusState(nextStatus) callbacksRef.current.onStatusChange?.(nextStatus) }, [callbacksRef]) const setPermission = React.useCallback((nextPermission: DocumentPermissionState) => { setPermissionState(nextPermission) callbacksRef.current.onPermissionChange?.(nextPermission) }, [callbacksRef]) const setDetection = React.useCallback((nextDetection: DocumentDetection | null) => { setDetectionState(nextDetection) callbacksRef.current.onDetectionChange?.(nextDetection) }, [callbacksRef]) const reportError = React.useCallback((cause: unknown, fallback: string) => { const nextError = createError(cause, fallback) setError(nextError) setStatus("error") callbacksRef.current.onError?.(nextError) return nextError }, [callbacksRef, setStatus]) const clearTimers = React.useCallback(() => { if (detectionTimerRef.current != null) window.clearTimeout(detectionTimerRef.current) if (autoCaptureTimerRef.current != null) window.clearTimeout(autoCaptureTimerRef.current) detectionTimerRef.current = null autoCaptureTimerRef.current = null }, []) const listCameras = React.useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) return [] const devices = await navigator.mediaDevices.enumerateDevices() const nextCameras = devices .filter((device) => device.kind === "videoinput") .map((device, index) => ({ deviceId: device.deviceId, groupId: device.groupId, label: device.label || `Camera ${index + 1}`, })) setCameras(nextCameras) callbacksRef.current.onDevicesChange?.(nextCameras) return nextCameras }, [callbacksRef]) const stop = React.useCallback(() => { clearTimers() for (const track of streamRef.current?.getTracks() ?? []) track.stop() streamRef.current = null if (videoRef.current) videoRef.current.srcObject = null previousDetectionRef.current = null stableFramesRef.current = 0 scanInProgressRef.current = false setCameraActive(false) setTorchSupported(false) setTorchEnabled(false) setDetection(null) setStatus("idle") callbacksRef.current.onCameraChange?.(false) callbacksRef.current.onTorchChange?.(false) }, [callbacksRef, clearTimers, setDetection, setStatus]) const start = React.useCallback(async () => { if (!window.isSecureContext && window.location.hostname !== "localhost") { setPermission("unsupported") reportError(new Error(labels?.insecureContext ?? "Camera access requires HTTPS or localhost"), "Camera access is unavailable") return } if (!navigator.mediaDevices?.getUserMedia) { setPermission("unsupported") reportError(new Error(labels?.cameraUnavailable ?? "MediaDevices is not supported"), "Camera access is unavailable") return } try { stop() setStatus("starting") setError(null) const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: currentCameraId ? { deviceId: { exact: currentCameraId }, width: { ideal: preferredWidth }, height: { ideal: preferredHeight }, frameRate: { ideal: preferredFrameRate }, } : { facingMode: { ideal: facingMode }, width: { ideal: preferredWidth }, height: { ideal: preferredHeight }, frameRate: { ideal: preferredFrameRate }, }, }) streamRef.current = stream const videoTrack = stream.getVideoTracks()[0] const settings = videoTrack?.getSettings() const capabilities = videoTrack?.getCapabilities?.() as MediaTrackCapabilities & { torch?: boolean } if (settings?.deviceId && settings.deviceId !== currentCameraId) setCurrentCameraId(settings.deviceId) setTorchSupported(Boolean(capabilities?.torch)) if (videoRef.current) { videoRef.current.srcObject = stream await videoRef.current.play() } setPermission("granted") setCameraActive(true) setStatus("ready") callbacksRef.current.onCameraChange?.(true) await listCameras() } catch (cause: unknown) { const nextError = createError(cause, "Camera could not be started") if (nextError.name === "NotAllowedError" || nextError.name === "SecurityError") setPermission("denied") reportError(nextError, "Camera could not be started") } }, [callbacksRef, currentCameraId, facingMode, labels?.cameraUnavailable, labels?.insecureContext, listCameras, preferredFrameRate, preferredHeight, preferredWidth, reportError, setCurrentCameraId, setPermission, setStatus, stop]) const switchCamera = React.useCallback(async (deviceId: string) => { setCurrentCameraId(deviceId) await new Promise((resolve) => window.requestAnimationFrame(() => resolve())) }, [setCurrentCameraId]) React.useEffect(() => { if (!cameraActive || !currentCameraId) return const activeDevice = streamRef.current?.getVideoTracks()[0]?.getSettings().deviceId if (activeDevice && activeDevice !== currentCameraId) void start() }, [cameraActive, currentCameraId, start]) const toggleTorch = React.useCallback(async (enabled?: boolean) => { const track = streamRef.current?.getVideoTracks()[0] if (!track || !torchSupported) return false const nextEnabled = enabled ?? !torchEnabled try { await track.applyConstraints({ advanced: [{ torch: nextEnabled } as MediaTrackConstraintSet] }) setTorchEnabled(nextEnabled) callbacksRef.current.onTorchChange?.(nextEnabled) return nextEnabled } catch (cause: unknown) { reportError(cause, "Torch could not be changed") return torchEnabled } }, [callbacksRef, reportError, torchEnabled, torchSupported]) const appendScan = React.useCallback((result: DocumentScanResult) => { const nextScans = [...currentScans, result].slice(-Math.max(1, maxScans)) setCurrentScans(nextScans) setCurrentSelectedScanId(result.id) callbacksRef.current.onScan?.(result) }, [callbacksRef, currentScans, maxScans, setCurrentScans, setCurrentSelectedScanId]) const processCanvas = React.useCallback(async ( canvas: HTMLCanvasElement, detectedCandidate?: DetectionCandidate | null ) => { const outputCanvas = outputCanvasRef.current if (!outputCanvas) throw new Error("Output canvas is unavailable") setStatus("processing") setEngineLoading(true) const cv = await getOpenCv() setEngineLoading(false) const source = cv.imread(canvas) try { const minimumArea = canvas.width * canvas.height * minDocumentAreaRatio const candidate = detectedCandidate ?? findDocument(cv, source, minimumArea) if (!candidate && requireDocument) { throw new Error(labels?.documentNotFound ?? "Document edges were not found") } const corners = candidate?.corners ?? fullFrameQuad(canvas.width, canvas.height) const warped = warpDocument(cv, source, corners) try { const processed = processOutput(cv, warped.output, currentProcessingMode, blackWhiteThreshold) try { const dimensions = fitOutputDimensions(warped.width, warped.height, maxOutputWidth, maxOutputHeight) let renderSource = processed let resized: any | null = null if (dimensions.width !== warped.width || dimensions.height !== warped.height) { resized = new cv.Mat() cv.resize(processed, resized, new cv.Size(dimensions.width, dimensions.height), 0, 0, cv.INTER_AREA) renderSource = resized } try { outputCanvas.width = dimensions.width outputCanvas.height = dimensions.height cv.imshow(outputCanvas, renderSource) const blob = await canvasToBlob(outputCanvas, outputType, outputQuality) return { id: createId(), blob, dataUrl: outputCanvas.toDataURL(outputType, outputQuality), width: dimensions.width, height: dimensions.height, sourceWidth: canvas.width, sourceHeight: canvas.height, corners, detected: Boolean(candidate), confidence: candidate?.confidence ?? 0, processingMode: currentProcessingMode, cameraId: currentCameraId, createdAt: Date.now(), } satisfies DocumentScanResult } finally { resized?.delete() } } finally { if (processed !== warped.output) processed.delete() } } finally { warped.output.delete() } } finally { source.delete() } }, [blackWhiteThreshold, currentCameraId, currentProcessingMode, labels?.documentNotFound, maxOutputHeight, maxOutputWidth, minDocumentAreaRatio, outputQuality, outputType, requireDocument, setStatus]) const scan = React.useCallback(async () => { if (scanInProgressRef.current) throw new Error("A scan is already in progress") const video = videoRef.current const sourceCanvas = sourceCanvasRef.current if (!video || !sourceCanvas || !video.videoWidth || !video.videoHeight) { throw reportError(new Error(labels?.cameraNotReady ?? "Camera frame is not ready"), "Camera frame is not ready") } scanInProgressRef.current = true clearTimers() setStatus("capturing") setError(null) try { const context = sourceCanvas.getContext("2d", { willReadFrequently: true }) if (!context) throw new Error("Canvas 2D context is unavailable") sourceCanvas.width = video.videoWidth sourceCanvas.height = video.videoHeight context.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height) const candidate = detection ? { corners: detection.corners, areaRatio: detection.areaRatio, confidence: detection.confidence } : null const result = await processCanvas(sourceCanvas, candidate) appendScan(result) setStatus(showReview ? "review" : "ready") if (stopAfterCapture) stop() return result } catch (cause: unknown) { throw reportError(cause, "Document could not be scanned") } finally { scanInProgressRef.current = false setEngineLoading(false) } }, [appendScan, clearTimers, detection, labels?.cameraNotReady, processCanvas, reportError, setStatus, showReview, stop, stopAfterCapture]) const scanFile = React.useCallback(async (file: File | Blob) => { if (scanInProgressRef.current) throw new Error("A scan is already in progress") const sourceCanvas = sourceCanvasRef.current if (!sourceCanvas) throw new Error("Source canvas is unavailable") scanInProgressRef.current = true setStatus("processing") setError(null) const url = URL.createObjectURL(file) try { const image = new window.Image() image.decoding = "async" image.src = url await image.decode() const context = sourceCanvas.getContext("2d", { willReadFrequently: true }) if (!context) throw new Error("Canvas 2D context is unavailable") sourceCanvas.width = image.naturalWidth sourceCanvas.height = image.naturalHeight context.drawImage(image, 0, 0) const result = await processCanvas(sourceCanvas) appendScan(result) setStatus(showReview ? "review" : cameraActive ? "ready" : "idle") return result } catch (cause: unknown) { throw reportError(cause, "Imported image could not be scanned") } finally { URL.revokeObjectURL(url) scanInProgressRef.current = false setEngineLoading(false) } }, [appendScan, cameraActive, processCanvas, reportError, setStatus, showReview]) const removeScan = React.useCallback((id: string) => { const nextScans = currentScans.filter((scanResult) => scanResult.id !== id) setCurrentScans(nextScans) if (currentSelectedScanId === id) setCurrentSelectedScanId(nextScans.at(-1)?.id ?? null) }, [currentScans, currentSelectedScanId, setCurrentScans, setCurrentSelectedScanId]) const clearScans = React.useCallback(() => { setCurrentScans([]) setCurrentSelectedScanId(null) }, [setCurrentScans, setCurrentSelectedScanId]) const downloadScan = React.useCallback((id?: string) => { const target = currentScans.find((scanResult) => scanResult.id === (id ?? currentSelectedScanId)) if (!target) return const anchor = window.document.createElement("a") anchor.href = target.dataUrl anchor.download = `document-scan-${new Date(target.createdAt).toISOString().replace(/[:.]/g, "-")}.${outputType?.split("/")[1] ?? "jpg"}` anchor.click() }, [currentScans, currentSelectedScanId, outputType]) const getSnapshot = React.useCallback((): DocumentScannerSnapshot => ({ status, permission, cameraActive, cameraId: currentCameraId, cameras, torchSupported, torchEnabled, detection, scans: currentScans, selectedScanId: currentSelectedScanId, error, }), [cameraActive, cameras, currentCameraId, currentScans, currentSelectedScanId, detection, error, permission, status, torchEnabled, torchSupported]) const runDetection = React.useCallback(async () => { if (!cameraActive || scanInProgressRef.current) return const video = videoRef.current const canvas = sourceCanvasRef.current if (!video || !canvas || !video.videoWidth || !video.videoHeight) return try { setStatus(stableFramesRef.current ? "detecting" : "ready") const scale = Math.min(1, 720 / video.videoWidth) canvas.width = Math.max(1, Math.round(video.videoWidth * scale)) canvas.height = Math.max(1, Math.round(video.videoHeight * scale)) const context = canvas.getContext("2d", { willReadFrequently: true }) if (!context) return context.drawImage(video, 0, 0, canvas.width, canvas.height) const cv = await getOpenCv() const source = cv.imread(canvas) try { const candidate = findDocument(cv, source, canvas.width * canvas.height * minDocumentAreaRatio) if (!candidate || candidate.confidence < minDetectionConfidence) { previousDetectionRef.current = null stableFramesRef.current = 0 setDetection(null) setStatus("detecting") return } const normalized = normalizeQuad(candidate.corners, canvas.width, canvas.height) const previous = previousDetectionRef.current stableFramesRef.current = previous && quadDelta(previous, normalized) <= stabilityTolerance ? stableFramesRef.current + 1 : 1 previousDetectionRef.current = normalized const stable = stableFramesRef.current >= stabilityFrames const scaledCorners = candidate.corners.map((point) => ({ x: point.x / scale, y: point.y / scale, })) as DocumentQuad const nextDetection = { corners: scaledCorners, areaRatio: candidate.areaRatio, confidence: candidate.confidence, stableFrames: stableFramesRef.current, stable, } setDetection(nextDetection) setStatus(stable ? "stable" : "detecting") if (autoCapture && stable && autoCaptureTimerRef.current == null) { autoCaptureTimerRef.current = window.setTimeout(() => { autoCaptureTimerRef.current = null void scan() }, autoCaptureDelayMs) } else if ((!autoCapture || !stable) && autoCaptureTimerRef.current != null) { window.clearTimeout(autoCaptureTimerRef.current) autoCaptureTimerRef.current = null } } finally { source.delete() } } catch (cause: unknown) { callbacksRef.current.onError?.(createError(cause, "Document detection failed")) } finally { if (cameraActive && !scanInProgressRef.current) { detectionTimerRef.current = window.setTimeout(() => void runDetection(), Math.max(80, detectionIntervalMs)) } } }, [autoCapture, autoCaptureDelayMs, callbacksRef, cameraActive, detectionIntervalMs, minDetectionConfidence, minDocumentAreaRatio, scan, setDetection, setStatus, stabilityFrames, stabilityTolerance]) React.useEffect(() => { clearTimers() if (cameraActive) detectionTimerRef.current = window.setTimeout(() => void runDetection(), 120) return clearTimers }, [cameraActive, clearTimers, runDetection]) React.useEffect(() => { if (autoStart) void start() return stop }, [autoStart, start, stop]) React.useEffect(() => { const onDeviceChange = () => void listCameras() navigator.mediaDevices?.addEventListener?.("devicechange", onDeviceChange) return () => navigator.mediaDevices?.removeEventListener?.("devicechange", onDeviceChange) }, [listCameras]) React.useImperativeHandle( forwardedRef, () => ({ start, stop, scan, scanFile, listCameras, switchCamera, toggleTorch, removeScan, clearScans, selectScan: setCurrentSelectedScanId, downloadScan, getSnapshot, video: videoRef.current, }), [clearScans, downloadScan, getSnapshot, listCameras, removeScan, scan, scanFile, setCurrentSelectedScanId, start, stop, switchCamera, toggleTorch] ) const normalizedDetectionPoints = React.useMemo(() => { const video = videoRef.current if (!detection || !video?.videoWidth || !video.videoHeight) return "" return detection.corners .map((point) => `${(point.x / video.videoWidth) * 100},${(point.y / video.videoHeight) * 100}`) .join(" ") }, [detection]) const selectedScan = currentScans.find((scanResult) => scanResult.id === currentSelectedScanId) ?? null const openFilePicker = React.useCallback(() => fileInputRef.current?.click(), []) const actions = React.useMemo(() => ({ start, stop, scan, listCameras, switchCamera, toggleTorch, removeScan, clearScans, selectScan: setCurrentSelectedScanId, downloadScan, openFilePicker, }), [clearScans, downloadScan, listCameras, openFilePicker, removeScan, scan, setCurrentSelectedScanId, start, stop, switchCamera, toggleTorch]) const toolbarContext = React.useMemo(() => ({ ...getSnapshot(), processingMode: currentProcessingMode, autoCapture, actions, }), [actions, autoCapture, currentProcessingMode, getSnapshot]) const previewMessage = detection?.stable ? labels?.documentAligned ?? "Document aligned" : detection ? labels?.holdSteady ?? "Hold steady" : labels?.detecting ?? "Looking for document edges" const defaultToolbar = (
{showCameraSelector && cameras.length > 1 ? ( ) : null}
{(["color", "grayscale", "black-white"] as DocumentProcessingMode[]).map((mode) => ( ))}
{allowFileImport ? ( ) : null} {allowTorch && torchSupported ? ( ) : null} {cameraActive ? ( ) : ( )}
) const defaultReview = ( ) const renderedToolbar = renderToolbar ? renderToolbar(toolbarContext) : defaultToolbar const renderedReview = renderReview ? renderReview(toolbarContext) : defaultReview const polygonTone = detection?.stable ? "#22c55e" : detection ? "#f59e0b" : "#ffffff" return (
{ const file = event.currentTarget.files?.[0] event.currentTarget.value = "" if (file) void scanFile(file) }} /> {beforePreview}
{showReview ? renderedReview : null}
{afterPreview}
) } ) DocumentScanner.displayName = "DocumentScanner" export { DocumentScanner }