npm package: @mleonard9/vin-scanner
Description: High-performance VIN scanner for React Native Vision Camera powered by Google ML Kit barcode + text recognition.
Author: mleonard9
Latest version: 1.5.2


<div align="center">

# @mleonard9/vin-scanner

High-performance VIN detection for React Native powered by Google ML Kit barcode + text recognition and `react-native-vision-camera`.

[Compiled from a combination of other community frame processing plugins](https://react-native-vision-camera.com/docs/guides/frame-processor-plugins-community) 
</div>

## Requirements

- `react-native-vision-camera` >= 4.6.0
- `react-native-worklets-core` >= 1.3.3
- `react-native-gesture-handler` >= 2.0.0 (for tap-to-focus)
- `react-native-reanimated` >= 3.0.0 (for tap-to-focus)
- iOS 13+ / Android 21+

## Compatibility

This package keeps peer dependency ranges broad so consuming apps can control their React Native, VisionCamera, and Worklets versions.

Tested matrix:

| Package | Recommended |
| --- | --- |
| `react-native-vision-camera` | `4.7.3` |
| `react-native-worklets-core` | `1.3.3` |

Notes:
- The package's own dev/test environment stays pinned to the matrix above, but published peer ranges remain open to support newer app stacks.
- `react-native-worklets-core` `1.6.x` has caused frame-processor HostObject compatibility issues with VisionCamera v4 in some apps.
- The package includes a frame unwrap compatibility shim to improve compatibility with newer Worklets wrappers.
- If your app is on newer Worklets or React Native, test carefully and prefer keeping VisionCamera and Worklets aligned to versions already validated in your app.

## Installation

```sh
yarn add @mleonard9/vin-scanner
# or
npm install @mleonard9/vin-scanner

# If you need a known-good baseline, start with:
# yarn add react-native-vision-camera@4.7.3 react-native-worklets-core@1.3.3

# Optional (for haptic cues)
# yarn add react-native-haptic-feedback

# iOS
cd ios && pod install
```

## Usage

```tsx
import React, { useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useCameraDevice } from 'react-native-vision-camera';
import {
  Camera as VinScannerCamera,
  useVinScanner,
  type VinCandidate,
} from '@mleonard9/vin-scanner';

export function VinScannerExample(): JSX.Element {
  const device = useCameraDevice('back');
  const [results, setResults] = useState<VinCandidate[] | null>(null);

  const options = useMemo(
    () => ({
      // Include QR for corner codes on auction tags
      barcode: { formats: ['code-39', 'code-128', 'pdf-417', 'qr'] },
      onResult: (candidates, event) => {
        setResults(candidates);
        console.log(`Scan took ${event.duration}ms`);
      },
    }),
    []
  );

  const { frameProcessor } = useVinScanner(options);

  if (device == null) {
    return null;
  }

  return (
    <View style={StyleSheet.absoluteFill}>
      <VinScannerCamera
        style={StyleSheet.absoluteFill}
        device={device}
        frameProcessor={frameProcessor}
        callback={(event) => setResults(event.candidates)}
      />
    </View>
  );
}
```

Every frame, the camera runs ML Kit barcode + text recognition, extracts 17-character VIN candidates, validates them (checksum included), and routes a payload to `callback`.

## Camera Gestures

The VIN Scanner camera includes built-in support for intuitive camera controls:

### Pinch to Zoom

Pinch-to-zoom is **enabled by default**. Simply pinch on the camera view to zoom in and out. The zoom gesture is natively implemented by `react-native-vision-camera` for optimal performance.

### Tap to Focus

Tap anywhere on the camera view to focus at that point. This feature requires `react-native-gesture-handler` and `react-native-reanimated`:

**Installation:**

```sh
yarn add react-native-gesture-handler react-native-reanimated
# or
npm install react-native-gesture-handler react-native-reanimated

# iOS
cd ios && pod install
```

**Note:** These dependencies are likely already installed if you're using React Navigation or other common React Native libraries.

The tap-to-focus functionality works automatically once these dependencies are installed. Simply tap on the camera view where you want to focus, and the camera will adjust both auto-focus (AF) and auto-exposure (AE) for that point.

**How it works:**
- Tap on a VIN to focus precisely on that area
- The camera adjusts focus and exposure automatically
- Works seamlessly with the pinch-to-zoom gesture
- No additional configuration required


## Advanced Features

### AR Overlay (deprecated)

The Skia-based overlay component has been removed while alignment issues are addressed. Default barcode formats are tuned for VIN labels (`code-39`, `code-128`, `pdf-417`) with an automatic fallback to all formats after sustained misses. No Skia install is required.

**Confidence Scoring:**

Each `VinCandidate` includes a `confidence` score (0.0-1.0) calculated from:
- **Source reliability**: Barcodes score higher than OCR text (+0.3)
- **Text precision**: Element-level text scores higher than block-level (+0.2)
- **Context awareness**: VIN prefixes like "VIN:" increase confidence (+0.2)
- **Checksum validation**: All candidates pass ISO 3779 validation (+0.2)

Overlay colors by confidence:
- 🟢 **Green** (`confidence > 0.8`): High confidence
- 🟡 **Yellow** (`confidence 0.5-0.8`): Medium confidence  
- 🔴 **Red** (`confidence < 0.5`): Low confidence

### Smart Duplicate Filtering

By default, the scanner uses time-based debouncing to prevent duplicate callbacks for the same VIN:

```tsx
const { frameProcessor } = useVinScanner({
  duplicateDebounceMs: 1500, // Default: 1500ms
  onResult: (candidates) => {
    // Only called when a new VIN is detected or after debounce period
    console.log('New VIN detected:', candidates[0]?.value);
  },
});
```

This prevents callback spam when holding the camera steady on a VIN, improving UX in fast-paced scanning scenarios.

### Performance Telemetry

Every `VinScannerEvent` includes detailed performance metrics for data-driven optimization:

```tsx
const { frameProcessor } = useVinScanner({
  onResult: (candidates, event) => {
    if (event.performance) {
      console.log('Performance breakdown:');
      console.log(`  Barcode scan: ${event.performance.barcodeMs}ms`);
      console.log(`  Text recognition: ${event.performance.textMs}ms`);
      console.log(`  Validation: ${event.performance.validationMs}ms`);
      console.log(`  Total: ${event.performance.totalMs}ms`);
    }
  },
});
```

Use these metrics to:
- Identify performance bottlenecks (barcode vs text recognition)
- Optimize `textScanInterval` based on actual timing
- Monitor performance across different devices
- Track improvements after configuration changes

### Camera Settings Optimization

Configure camera parameters for device-specific optimization:

```tsx
const { frameProcessor } = useVinScanner({
  cameraSettings: {
    fps: 60,                           // Higher FPS for smoother scanning
    lowLightBoost: true,               // Auto-boost in low light (default)
    videoStabilizationMode: 'standard' // Reduce motion blur
  },
  onResult: (candidates) => {
    console.log('Detected:', candidates[0]?.value);
  },
});
```

**Available settings:**
- **`fps`**: Target frame rate (15-60). Higher = smoother but more CPU. Default: 30
- **`lowLightBoost`**: Auto-brighten in dark conditions. Default: true
- **`videoStabilizationMode`**: `'off'` | `'standard'` | `'cinematic'` | `'auto'`. Default: 'off'

**Tip**: For auction lanes with good lighting, try `fps: 60` and `videoStabilizationMode: 'standard'` for best results.

### Callback payload

```ts
type VinScannerEvent = {
  timestamp: number;
  duration: number;
  candidates: VinCandidate[];
  firstCandidate?: VinCandidate | null;
  raw: {
    barcodes: BarcodeDetection[];
    textBlocks: TextDetection[];
  };
};
```

`VinCandidate` contains `{ value, source: 'barcode' | 'text', confidence, boundingBox }`.  
The `candidates` array contains every potential VIN found in the frame. `firstCandidate` is a convenience reference to the best match.

### Options

| Path | Type | Description | Default |
| --- | --- | --- | --- |
| `options.barcode.enabled` | boolean | Enable barcode scanning | `true` |
| `options.barcode.formats` | `BarcodeFormat[]` | Restrict ML Kit formats (`'code-39'`, `'code-128'`, `'pdf-417'`, etc.) | `['all']` |
| `options.text.enabled` | boolean | Enable text recognition | `true` |
| `options.text.language` | `'latin' \| 'chinese' \| 'devanagari' \| 'japanese' \| 'korean'` | ML Kit language pack | `'latin'` |
| `options.text.requireConfirmation` | boolean | When true, text VINs are held until you confirm; barcodes still emit immediately | `false` |
| `options.text.pendingTtlMs` | number | Auto-dismiss pending text VINs after this many ms (when `requireConfirmation` is true) | `5000` |
| `options.detection.textScanInterval` | number | Run text recognition every Nth frame (1 = every frame) | `3` |
| `options.detection.maxFrameRate` | number | Max FPS budget for frame processing (drops surplus frames to avoid blocking) | `30` |
| `options.detection.forceOrientation` | `'portrait' \| 'portrait-upside-down' \| 'landscape-left' \| 'landscape-right'` | Forces ML Kit to interpret every frame using the given orientation (useful when the UI is locked to portrait but the sensor reports landscape) | `null` |
| `options.detection.scanRegion` | `ScanRegion` | Restrict ML Kit processing to a specific region of the frame (normalized coordinates 0.0-1.0). Significantly improves performance by ignoring irrelevant areas. | `{ x: 0.15, y: 0.15, width: 0.7, height: 0.7 }` |
| `options.detection.enableFrameQualityCheck` | boolean | Deprecated; when false it disables `minLuma`/`minSharpness` gates | `true` |
| `options.detection.minLuma` | number | Minimum mean luma (0–255) required to process a frame; skips too-dark frames | `30` |
| `options.detection.minSharpness` | number | Minimum sharpness metric required; skips blurry frames | `12` |
| `options.detection.minConfidence` | number | Minimum candidate confidence required before emitting | `0` |
| `options.detection.barcodeFallbackAfter` | number | Frames without barcode hits before scanning all formats | `45` |
| `options.duplicateDebounceMs` | number | Time in milliseconds to suppress duplicate VIN callbacks for the same value | `1500` |
| `options.showOverlay` | boolean | Deprecated; overlay component removed | `false` |
| `options.overlayColors` | `OverlayColors` | Deprecated; overlay component removed | `{ high: '#00FF00', medium: '#FFFF00', low: '#FF0000' }` |
| `options.cameraSettings` | `CameraSettings` | Camera configuration: `{ fps (clamped 24–30), lowLightBoost, videoStabilizationMode }` | `{ fps: 24, lowLightBoost: true, videoStabilizationMode: 'cinematic' }` |
| `options.onResult` | `(candidates, event) => void` | Convenience callback when using `useVinScanner`; receives all candidates and the raw event | `undefined` |
| `options.onTextPending` | `(pending) => void` | Invoked when `text.requireConfirmation` is true and text VINs are detected | `undefined` |
| `options.haptics` | boolean | Enable built-in haptic cues (requires `react-native-haptic-feedback` installed) | `true` |

### Behaviors & defaults
- Barcode-first: barcodes emit immediately; text VINs can require confirmation.
- Session dedupe: VINs are not re-emitted within a scan session (in addition to time-based debounce).
- Quality gate: frames below `minLuma` or `minSharpness` are skipped.
- Confidence gate: candidates below `minConfidence` are dropped, but the default is `0` because checksum validity is the primary source of truth.
- Barcode formats: defaults to `code-39`, `code-128`, `pdf-417` with automatic fallback to all formats after `barcodeFallbackAfter` empty frames.
- Camera hints: FPS clamped to 24–30 and `videoStabilizationMode` defaults to `cinematic` to keep headroom and reduce jitter.

### Text confirmation UI (barcode = instant, text = tap-to-confirm)

```tsx
import { useState } from 'react';
import { Camera, useCameraDevice } from 'react-native-vision-camera';
import { useVinScanner, TextVinPrompt, VinCandidate } from '@mleonard9/vin-scanner';

export function ConfirmingScanner() {
  const device = useCameraDevice('back');
  const [pending, setPending] = useState<VinCandidate[]>([]);

  const { frameProcessor, pendingTextCandidates, confirmTextCandidate } = useVinScanner({
    text: { requireConfirmation: true },
    onTextPending: setPending,
    onResult: (candidates) => {
      // barcode VINs (or confirmed text VINs) arrive here
      console.log('confirmed VINs', candidates.map((c) => c.value));
    },
  });

  return (
    <>
      {device && (
        <Camera style={{ flex: 1 }} device={device} frameProcessor={frameProcessor} isActive />
      )}
      <TextVinPrompt
        visible={pendingTextCandidates.length > 0}
        candidates={pendingTextCandidates}
        buttonLabel=\"Book It\"
        buttonColor=\"#0A84FF\"
        onConfirm={(candidate) => confirmTextCandidate(candidate.value)}
        onDismiss={() => setPending([])}
      />
    </>
  );
}
```

### Manual VIN keypad with checksum guard

```tsx
import { ManualVinInput } from '@mleonard9/vin-scanner';

export function ManualEntry({ onSubmit }: { onSubmit: (vin: string) => void }) {
  return (
    <ManualVinInput
      buttonLabel=\"Book It\"
      buttonColor=\"#0A84FF\"
      onSubmit={onSubmit}
    />
  );
}
```

### Pending banner (alternative to modal)

```tsx
import { PendingVinBanner } from '@mleonard9/vin-scanner';

<PendingVinBanner
  visible={pendingTextCandidates.length > 0}
  candidates={pendingTextCandidates}
  buttonLabel=\"Book It\"
  buttonColor=\"#0A84FF\"
  onConfirm={(candidate) => confirmTextCandidate(candidate.value)}
  onDismiss={() => setPending([])}
/>;
```

### OCRScanner-style chrome overlay

```tsx
import { ScannerChromeOverlay } from '@mleonard9/vin-scanner';

<ScannerChromeOverlay
  onBackPress={() => navigation.goBack()}
  onManualEntryPress={() => setManualVisible(true)}
  onFlashPress={toggleTorch}
  isTorchOn={isTorchOn}
  manualEntryContent={<Ionicons name="keypad-outline" size={32} color="white" />}
  flashOffContent={<Ionicons name="flash-outline" size={32} color="white" />}
  flashOnContent={<Ionicons name="flash-off-outline" size={32} color="white" />}
  backContent={<Ionicons name="chevron-back-outline" size={32} color="white" />}
/>;
```

### Performance

Phase 1 optimizations dramatically improve scanning performance through native ROI (Region of Interest) frame cropping:

| Configuration | Avg Duration | Improvement |
| --- | --- | --- |
| Full frame, every frame | ~180ms | baseline |
| ROI scanning (70% center) | ~95ms | **47% faster** |
| ROI + text interval (3 frames) | ~45ms | **75% faster** |
| ROI + quality check + throttle | ~30ms | **83% faster** |

**Default configuration** uses ROI scanning (`scanRegion: { x: 0.15, y: 0.15, width: 0.7, height: 0.7 }`) and a text scan interval of 3. This provides excellent accuracy while maintaining real-time performance on mid-range devices.

**Tip:** For challenging lighting or distance scenarios, set `textScanInterval: 1` to scan every frame at the cost of higher CPU usage.

**Custom scan regions:**

```tsx
const { frameProcessor } = useVinScanner({
  detection: {
    // Focus on center 50% of frame (set null to cover full frame / corner QR)
    scanRegion: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 },
    textScanInterval: 2,
  },
  onResult: (candidates) => {
    console.log('Detected VINs:', candidates);
  },
});
```


### Advanced frame-processor controls

- **Per-frame plugin overrides:** both barcode and text frame processor plugins accept per-frame arguments, so you can dynamically change ML Kit barcode formats or text recognition language without reinitializing the plugin. Call `barcodeScanner.scanBarcodes(frame, { 'pdf-417': true })` or `textScanner.scanText(frame, { language: 'japanese' })` inside your worklet to override the resolved defaults for a single frame.
- **Orientation overrides:** If your UI is locked to portrait (e.g., iPad kiosks) but VisionCamera streams landscape buffers, set `detection.forceOrientation: 'portrait'`. The JS hook forwards that override to the native plugins so ML Kit always interprets frames with the requested rotation, eliminating the “upside-down unless I flip the paper” problem described in the [VisionCamera orientation guide](https://react-native-vision-camera.com/docs/guides/orientation).
- **Shared bounding boxes:** native plugins now stream bounding box coordinates via zero-copy shared arrays, minimizing JSI serialization. The hook translates these buffers into the familiar `BoundingBox` structures before running VIN heuristics, so no API change is required.
- **Orientation-safe processing:** the native plugins forward VisionCamera’s frame orientation metadata directly into ML Kit as recommended in the [VisionCamera orientation guide](https://react-native-vision-camera.com/docs/guides/orientation), ensuring portrait VIN scans stay upright.

### Hook-only usage

If you prefer to configure `react-native-vision-camera` yourself, grab the frame processor from the hook:

```tsx
const { frameProcessor } = useVinScanner({
  onResult: (candidates, event) => {
    console.log('Current VINs', candidates, event.firstCandidate);
    console.log(`Duration: ${event.duration}ms`);
  },
});

return (
  <Camera
    ref={cameraRef}
    device={device}
    frameProcessor={frameProcessor}
    pixelFormat="yuv"
    style={StyleSheet.absoluteFill}
  />
);
```

## Publishing (internal use)

This package is scoped (`@mleonard9/vin-scanner`). To release a new build:

```sh
yarn prepare   # builds /lib via bob
npm publish --access public
```

Ensure the authenticated npm user has access to the `@mleonard9` scope.