@mleonard9/vin-scanner 1.3.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +123 -38
  2. package/android/src/main/java/com/visioncamerabarcodescanner/VisionCameraBarcodeScannerModule.kt +26 -17
  3. package/lib/commonjs/ManualVinInput.js +146 -0
  4. package/lib/commonjs/ManualVinInput.js.map +1 -0
  5. package/lib/commonjs/PendingVinBanner.js +120 -0
  6. package/lib/commonjs/PendingVinBanner.js.map +1 -0
  7. package/lib/commonjs/TextVinPrompt.js +132 -0
  8. package/lib/commonjs/TextVinPrompt.js.map +1 -0
  9. package/lib/commonjs/haptics.js +36 -0
  10. package/lib/commonjs/haptics.js.map +1 -0
  11. package/lib/commonjs/index.js +184 -13
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/scanBarcodes.js.map +1 -1
  14. package/lib/commonjs/scanText.js.map +1 -1
  15. package/lib/commonjs/useVinScanner.js +164 -6
  16. package/lib/commonjs/useVinScanner.js.map +1 -1
  17. package/lib/commonjs/vinUtils.js +26 -12
  18. package/lib/commonjs/vinUtils.js.map +1 -1
  19. package/lib/module/ManualVinInput.js +138 -0
  20. package/lib/module/ManualVinInput.js.map +1 -0
  21. package/lib/module/PendingVinBanner.js +112 -0
  22. package/lib/module/PendingVinBanner.js.map +1 -0
  23. package/lib/module/TextVinPrompt.js +124 -0
  24. package/lib/module/TextVinPrompt.js.map +1 -0
  25. package/lib/module/haptics.js +27 -0
  26. package/lib/module/haptics.js.map +1 -0
  27. package/lib/module/index.js +171 -12
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/scanBarcodes.js.map +1 -1
  30. package/lib/module/scanText.js.map +1 -1
  31. package/lib/module/useVinScanner.js +165 -7
  32. package/lib/module/useVinScanner.js.map +1 -1
  33. package/lib/module/vinUtils.js +26 -12
  34. package/lib/module/vinUtils.js.map +1 -1
  35. package/lib/typescript/src/ManualVinInput.d.ts +11 -0
  36. package/lib/typescript/src/ManualVinInput.d.ts.map +1 -0
  37. package/lib/typescript/src/PendingVinBanner.d.ts +17 -0
  38. package/lib/typescript/src/PendingVinBanner.d.ts.map +1 -0
  39. package/lib/typescript/src/TextVinPrompt.d.ts +20 -0
  40. package/lib/typescript/src/TextVinPrompt.d.ts.map +1 -0
  41. package/lib/typescript/src/haptics.d.ts +4 -0
  42. package/lib/typescript/src/haptics.d.ts.map +1 -0
  43. package/lib/typescript/src/index.d.ts +3 -1
  44. package/lib/typescript/src/index.d.ts.map +1 -1
  45. package/lib/typescript/src/scanBarcodes.d.ts.map +1 -1
  46. package/lib/typescript/src/scanText.d.ts.map +1 -1
  47. package/lib/typescript/src/types.d.ts +46 -7
  48. package/lib/typescript/src/types.d.ts.map +1 -1
  49. package/lib/typescript/src/useVinScanner.d.ts +3 -1
  50. package/lib/typescript/src/useVinScanner.d.ts.map +1 -1
  51. package/lib/typescript/src/vinUtils.d.ts +7 -2
  52. package/lib/typescript/src/vinUtils.d.ts.map +1 -1
  53. package/package.json +5 -3
  54. package/src/ManualVinInput.tsx +184 -0
  55. package/src/PendingVinBanner.tsx +127 -0
  56. package/src/TextVinPrompt.tsx +147 -0
  57. package/src/haptics.ts +29 -0
  58. package/src/index.tsx +219 -22
  59. package/src/scanBarcodes.ts +5 -1
  60. package/src/scanText.ts +5 -1
  61. package/src/types.ts +55 -11
  62. package/src/useVinScanner.ts +209 -19
  63. package/src/vinUtils.ts +141 -82
  64. package/lib/commonjs/VinScannerOverlay.js +0 -60
  65. package/lib/commonjs/VinScannerOverlay.js.map +0 -1
  66. package/lib/module/VinScannerOverlay.js +0 -53
  67. package/lib/module/VinScannerOverlay.js.map +0 -1
  68. package/lib/typescript/src/VinScannerOverlay.d.ts +0 -14
  69. package/lib/typescript/src/VinScannerOverlay.d.ts.map +0 -1
  70. package/src/VinScannerOverlay.tsx +0 -55
package/src/index.tsx CHANGED
@@ -1,10 +1,12 @@
1
- import React, { forwardRef, useMemo } from 'react';
1
+ import React, { forwardRef, useMemo, useCallback } from 'react';
2
2
  import {
3
3
  Camera as VisionCamera,
4
4
  type ReadonlyFrameProcessor,
5
5
  useFrameProcessor,
6
6
  } from 'react-native-vision-camera';
7
7
  import { useRunOnJS } from 'react-native-worklets-core';
8
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
9
+ import { runOnJS } from 'react-native-reanimated';
8
10
  import { createBarcodeScannerPlugin } from './scanBarcodes';
9
11
  import { createTextRecognitionPlugin } from './scanText';
10
12
  import type {
@@ -35,11 +37,44 @@ export const Camera = forwardRef(function Camera(
35
37
  const barcodeScanner = useBarcodeScanner(resolvedOptions.barcode);
36
38
  const textScanner = useTextScanner(resolvedOptions.text);
37
39
 
40
+ const lastEmittedVin = React.useRef<string | null>(null);
41
+ const lastEmitTimestamp = React.useRef(0);
42
+ const lastFrameTimestampRef = React.useRef(0);
43
+ const frameCounterRef = React.useRef(0);
44
+ const sessionSeen = React.useRef<Set<string>>(new Set());
45
+
38
46
  const handleDetections = useRunOnJS(
39
47
  (payload: WorkletPayload) => {
40
48
  const candidates = buildVinCandidates(payload, resolvedOptions);
41
- const firstCandidate = pickFirstCandidate(candidates, resolvedOptions);
49
+ const barcodeCandidates = candidates.filter(
50
+ (c) => c.source === 'barcode'
51
+ );
52
+ const textCandidates = candidates.filter((c) => c.source === 'text');
53
+
54
+ if (barcodeCandidates.length > 0) {
55
+ const first = pickFirstCandidate(barcodeCandidates, resolvedOptions);
56
+ callback({
57
+ timestamp: payload.timestamp,
58
+ duration: 0,
59
+ candidates: barcodeCandidates,
60
+ firstCandidate: first,
61
+ raw: {
62
+ barcodes: payload.barcodes ?? [],
63
+ textBlocks: payload.textBlocks ?? [],
64
+ },
65
+ });
66
+ return;
67
+ }
68
+
69
+ if (
70
+ resolvedOptions.text.requireConfirmation &&
71
+ textCandidates.length > 0
72
+ ) {
73
+ options?.onTextPending?.(textCandidates);
74
+ return;
75
+ }
42
76
 
77
+ const firstCandidate = pickFirstCandidate(candidates, resolvedOptions);
43
78
  callback({
44
79
  timestamp: payload.timestamp,
45
80
  duration: 0,
@@ -51,41 +86,201 @@ export const Camera = forwardRef(function Camera(
51
86
  },
52
87
  });
53
88
  },
54
- [callback, resolvedOptions]
89
+ [callback, options?.onTextPending, resolvedOptions]
55
90
  );
56
91
 
57
92
  const scanBarcodes = barcodeScanner?.scanBarcodes;
58
93
  const scanText = textScanner?.scanText;
59
94
 
95
+ const noBarcodeFrameCount = React.useRef(0);
96
+ const useBarcodeFallback = React.useRef(false);
97
+
60
98
  const frameProcessor: ReadonlyFrameProcessor = useFrameProcessor(
61
99
  (frame) => {
62
100
  'worklet';
63
- const barcodes = scanBarcodes ? scanBarcodes(frame) : [];
64
- const textBlocks = scanText ? scanText(frame) : [];
65
- handleDetections({
101
+
102
+ const now =
103
+ typeof frame?.timestamp === 'number' ? frame.timestamp : Date.now();
104
+
105
+ // Max FPS throttle (matches hook behavior)
106
+ const maxFps = resolvedOptions.detection.maxFrameRate;
107
+ if (maxFps > 0) {
108
+ const minInterval = 1000 / maxFps;
109
+ if (
110
+ lastFrameTimestampRef.current > 0 &&
111
+ now - lastFrameTimestampRef.current < minInterval
112
+ ) {
113
+ return;
114
+ }
115
+ lastFrameTimestampRef.current = now;
116
+ }
117
+
118
+ const orientationOverride =
119
+ resolvedOptions.detection.forceOrientation ?? null;
120
+ const regionOverride = resolvedOptions.detection.scanRegion ?? null;
121
+
122
+ // Frame quality gate (luma + sharpness)
123
+ const minLuma = resolvedOptions.detection.minLuma;
124
+ const minSharpness = resolvedOptions.detection.minSharpness;
125
+ if (minLuma > 0 || minSharpness > 0) {
126
+ const planes = (frame as any)?.planes;
127
+ if (planes?.length > 0) {
128
+ const yPlane = planes[0];
129
+ const bytes: Uint8Array | undefined = yPlane?.bytes;
130
+ const width: number | undefined = yPlane?.width;
131
+ const height: number | undefined = yPlane?.height;
132
+ const stride: number | undefined = yPlane?.bytesPerRow;
133
+ if (bytes && width && height && stride) {
134
+ let lumaSum = 0;
135
+ const sampleStep = 8;
136
+ const sampleCount = Math.floor(bytes.length / sampleStep);
137
+ for (let i = 0; i < bytes.length; i += sampleStep) {
138
+ lumaSum += bytes[i]!;
139
+ }
140
+ const meanLuma = lumaSum / Math.max(1, sampleCount);
141
+ if (minLuma > 0 && meanLuma < minLuma) {
142
+ return;
143
+ }
144
+
145
+ if (minSharpness > 0) {
146
+ const step = Math.max(2, Math.floor(width / 96));
147
+ let sharpAccum = 0;
148
+ let sharpCount = 0;
149
+ for (let y = step; y < height - step; y += step) {
150
+ const row = y * stride;
151
+ for (let x = step; x < width - step; x += step) {
152
+ const idx = row + x;
153
+ const gx = bytes[idx + 1]! - bytes[idx - 1]!;
154
+ const gy = bytes[idx + stride]! - bytes[idx - stride]!;
155
+ sharpAccum += Math.abs(gx) + Math.abs(gy);
156
+ sharpCount += 1;
157
+ }
158
+ }
159
+ const sharpness = sharpAccum / Math.max(1, sharpCount);
160
+ if (sharpness < minSharpness) {
161
+ return;
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ const pluginArgs =
169
+ orientationOverride || regionOverride
170
+ ? {
171
+ ...(orientationOverride && { orientation: orientationOverride }),
172
+ ...(regionOverride && { scanRegion: regionOverride }),
173
+ }
174
+ : undefined;
175
+
176
+ // Run barcode every frame
177
+ const barcodes = scanBarcodes
178
+ ? scanBarcodes(
179
+ frame,
180
+ useBarcodeFallback.current ? { all: true } : pluginArgs
181
+ )
182
+ : [];
183
+
184
+ // Run text every Nth frame per textScanInterval
185
+ frameCounterRef.current += 1;
186
+ const frameIndex = frameCounterRef.current;
187
+ const textScanInterval = resolvedOptions.detection.textScanInterval;
188
+ const shouldRunText =
189
+ scanText &&
190
+ (textScanInterval <= 1 || frameIndex % textScanInterval === 0);
191
+ let textBlocks =
192
+ shouldRunText && scanText ? (scanText(frame, pluginArgs) ?? []) : [];
193
+ if (
194
+ scanText &&
195
+ (textBlocks ?? []).length === 0 &&
196
+ (barcodes ?? []).length === 0 &&
197
+ !shouldRunText
198
+ ) {
199
+ textBlocks = scanText(frame, pluginArgs) ?? [];
200
+ }
201
+
202
+ const payload = {
66
203
  barcodes: barcodes ?? [],
67
204
  textBlocks: textBlocks ?? [],
68
- timestamp:
69
- typeof frame?.timestamp === 'number' ? frame.timestamp : Date.now(),
70
- });
205
+ timestamp: now,
206
+ } as WorkletPayload;
207
+
208
+ const candidates = buildVinCandidates(payload, resolvedOptions).filter(
209
+ (c) => c.confidence >= resolvedOptions.detection.minConfidence
210
+ );
211
+ const firstCandidate = pickFirstCandidate(candidates, resolvedOptions);
212
+
213
+ // Adaptive barcode fallback: if no barcode hits for N frames, scan all formats.
214
+ const hasBarcode = (payload.barcodes ?? []).length > 0;
215
+ if (!hasBarcode) {
216
+ noBarcodeFrameCount.current += 1;
217
+ if (
218
+ noBarcodeFrameCount.current >=
219
+ resolvedOptions.detection.barcodeFallbackAfter
220
+ ) {
221
+ useBarcodeFallback.current = true;
222
+ }
223
+ } else {
224
+ noBarcodeFrameCount.current = 0;
225
+ useBarcodeFallback.current = false;
226
+ }
227
+
228
+ // Duplicate debounce (matches hook behavior)
229
+ if (firstCandidate) {
230
+ const debounceMs = resolvedOptions.duplicateDebounceMs ?? 1500;
231
+ const isDuplicate =
232
+ firstCandidate.value === lastEmittedVin.current &&
233
+ now - lastEmitTimestamp.current < debounceMs;
234
+ const seen = sessionSeen.current.has(firstCandidate.value);
235
+
236
+ if (isDuplicate || seen) {
237
+ return;
238
+ }
239
+ lastEmittedVin.current = firstCandidate.value;
240
+ lastEmitTimestamp.current = now;
241
+ sessionSeen.current.add(firstCandidate.value);
242
+ }
243
+
244
+ handleDetections(payload);
245
+ },
246
+ [scanBarcodes, scanText, handleDetections, resolvedOptions]
247
+ );
248
+
249
+ // Tap to focus callback
250
+ const focus = useCallback(
251
+ (point: { x: number; y: number }) => {
252
+ const camera = (ref as any)?.current;
253
+ if (camera == null) return;
254
+ camera.focus(point);
71
255
  },
72
- [scanBarcodes, scanText, handleDetections]
256
+ [ref]
257
+ );
258
+
259
+ // Create tap gesture for focus
260
+ const tapGesture = Gesture.Tap().onEnd(
261
+ ({ x, y }: { x: number; y: number }) => {
262
+ runOnJS(focus)({ x, y });
263
+ }
73
264
  );
74
265
 
75
266
  return (
76
267
  <>
77
268
  {!!device && (
78
- <VisionCamera
79
- pixelFormat="yuv"
80
- ref={ref}
81
- frameProcessor={frameProcessor}
82
- device={device}
83
- enableZoomGesture={true}
84
- lowLightBoost={options?.cameraSettings?.lowLightBoost ?? true}
85
- videoStabilizationMode={options?.cameraSettings?.videoStabilizationMode ?? 'off'}
86
- fps={options?.cameraSettings?.fps ?? 30}
87
- {...rest}
88
- />
269
+ <GestureDetector gesture={tapGesture}>
270
+ <VisionCamera
271
+ pixelFormat="yuv"
272
+ ref={ref}
273
+ frameProcessor={frameProcessor}
274
+ device={device}
275
+ enableZoomGesture={true}
276
+ lowLightBoost={options?.cameraSettings?.lowLightBoost ?? true}
277
+ videoStabilizationMode={
278
+ options?.cameraSettings?.videoStabilizationMode ?? 'cinematic'
279
+ }
280
+ fps={Math.min(30, Math.max(24, options?.cameraSettings?.fps ?? 24))}
281
+ {...rest}
282
+ />
283
+ </GestureDetector>
89
284
  )}
90
285
  </>
91
286
  );
@@ -135,5 +330,7 @@ export type {
135
330
  } from './types';
136
331
 
137
332
  export { useVinScanner } from './useVinScanner';
138
- export { VinScannerOverlay } from './VinScannerOverlay';
139
333
  export { isValidVin } from './vinUtils';
334
+ export { TextVinPrompt } from './TextVinPrompt';
335
+ export { PendingVinBanner } from './PendingVinBanner';
336
+ export { ManualVinInput } from './ManualVinInput';
@@ -116,7 +116,11 @@ const mergeArgs = (
116
116
  'worklet';
117
117
  const merged: BarcodePluginArgs = { ...baseArgs };
118
118
  if (overrides) {
119
- const { orientation: overrideOrientation, scanRegion: overrideScanRegion, ...formatMap } = overrides;
119
+ const {
120
+ orientation: overrideOrientation,
121
+ scanRegion: overrideScanRegion,
122
+ ...formatMap
123
+ } = overrides;
120
124
  Object.entries(formatMap).forEach(([key, value]) => {
121
125
  merged[key as BarcodeFormat] = value as boolean | undefined;
122
126
  });
package/src/scanText.ts CHANGED
@@ -90,7 +90,11 @@ const mergeTextArgs = (
90
90
  ...base,
91
91
  };
92
92
  if (overrides) {
93
- const { orientation: overrideOrientation, scanRegion: overrideScanRegion, ...rest } = overrides;
93
+ const {
94
+ orientation: overrideOrientation,
95
+ scanRegion: overrideScanRegion,
96
+ ...rest
97
+ } = overrides;
94
98
  Object.assign(merged, rest);
95
99
  if (overrideScanRegion) {
96
100
  merged.scanRegion = overrideScanRegion;
package/src/types.ts CHANGED
@@ -102,7 +102,12 @@ export type BoundingBox = {
102
102
 
103
103
  export type VinCandidateSource = 'barcode' | 'text';
104
104
 
105
- export type VinTextOrigin = 'rawValue' | 'displayValue' | 'block' | 'line' | 'element';
105
+ export type VinTextOrigin =
106
+ | 'rawValue'
107
+ | 'displayValue'
108
+ | 'block'
109
+ | 'line'
110
+ | 'element';
106
111
 
107
112
  export type VinCandidate = {
108
113
  value: string;
@@ -112,9 +117,9 @@ export type VinCandidate = {
112
117
  origin?: VinTextOrigin;
113
118
  boundingBox?: BoundingBox;
114
119
  rawPayload?:
115
- | BarcodeDetection
116
- | TextDetection
117
- | { resultText?: string;[key: string]: unknown };
120
+ | BarcodeDetection
121
+ | TextDetection
122
+ | { resultText?: string; [key: string]: unknown };
118
123
  };
119
124
 
120
125
  export type VinScannerEvent = {
@@ -147,6 +152,17 @@ export type BarcodeScannerOptions = {
147
152
  export type TextScannerOptions = {
148
153
  enabled?: boolean;
149
154
  language?: TextRecognitionLanguage;
155
+ /**
156
+ * When true, text VINs are surfaced via `onTextPending` and must be
157
+ * confirmed manually; barcode VINs still emit immediately.
158
+ * Default: false
159
+ */
160
+ requireConfirmation?: boolean;
161
+ /**
162
+ * Auto-dismiss pending text candidates after this many ms.
163
+ * Default: 5000
164
+ */
165
+ pendingTtlMs?: number;
150
166
  };
151
167
 
152
168
  export type ScanRegion = {
@@ -196,9 +212,29 @@ export type DetectionOptions = {
196
212
  /**
197
213
  * Enable intelligent frame quality checks to skip blurry or dark frames.
198
214
  * This improves accuracy by only processing high-quality frames.
199
- * Default: true
215
+ * Note: currently unused; retained for backward compatibility.
200
216
  */
201
217
  enableFrameQualityCheck?: boolean;
218
+ /**
219
+ * Minimum average luma (0-255) required to process the frame. Lower = darker.
220
+ * Set to 0 to disable.
221
+ */
222
+ minLuma?: number;
223
+ /**
224
+ * Minimum sharpness score (simple gradient metric) required to process.
225
+ * Set to 0 to disable.
226
+ */
227
+ minSharpness?: number;
228
+ /**
229
+ * Minimum candidate confidence required before emitting.
230
+ * Default: 0.6
231
+ */
232
+ minConfidence?: number;
233
+ /**
234
+ * Frames without barcode hits before falling back to scanning all formats.
235
+ * Default: 45 frames.
236
+ */
237
+ barcodeFallbackAfter?: number;
202
238
  };
203
239
 
204
240
  export type OverlayColors = {
@@ -211,11 +247,11 @@ export type OverlayColors = {
211
247
  };
212
248
 
213
249
  export type CameraSettings = {
214
- /** Target FPS for camera. Default: 30 */
250
+ /** Target FPS for camera. Clamped to 24–30. Default: 24 */
215
251
  fps?: number;
216
252
  /** Enable low light boost. Default: true */
217
253
  lowLightBoost?: boolean;
218
- /** Video stabilization mode. Default: 'off' */
254
+ /** Video stabilization mode. Default: 'cinematic' */
219
255
  videoStabilizationMode?: 'off' | 'standard' | 'cinematic' | 'auto';
220
256
  };
221
257
 
@@ -225,13 +261,21 @@ export type VinScannerOptions = {
225
261
  detection?: DetectionOptions;
226
262
  onResult?: (result: VinCandidate[], event: VinScannerEvent) => void;
227
263
  /**
228
- * Show AR overlay with bounding boxes around detected VINs.
229
- * Requires @shopify/react-native-skia peer dependency.
230
- * Default: false
264
+ * Called when text candidates are detected and `text.requireConfirmation` is true.
265
+ * Use this to show a confirmation UI and then call `confirmTextCandidate`.
266
+ */
267
+ onTextPending?: (pending: VinCandidate[]) => void;
268
+ /**
269
+ * Enable built-in haptic notifications (if react-native-haptic-feedback is installed).
270
+ * Default: true
271
+ */
272
+ haptics?: boolean;
273
+ /**
274
+ * Deprecated: overlay component removed. Ignored.
231
275
  */
232
276
  showOverlay?: boolean;
233
277
  /**
234
- * Custom colors for overlay bounding boxes based on confidence.
278
+ * Deprecated: overlay component removed. Ignored.
235
279
  */
236
280
  overlayColors?: Partial<OverlayColors>;
237
281
  /**