@trustchex/react-native-sdk 1.472.0 → 1.475.1

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 (30) hide show
  1. package/android/build.gradle +3 -3
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
  3. package/ios/Camera/TrustchexCameraView.swift +92 -19
  4. package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
  5. package/lib/module/Screens/Static/ResultScreen.js +5 -0
  6. package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
  7. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  8. package/lib/module/Shared/Libs/mrz.utils.js +639 -16
  9. package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
  10. package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
  11. package/lib/module/version.js +1 -1
  12. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  13. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  14. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  15. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  16. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  17. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
  18. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
  19. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
  20. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
  21. package/lib/typescript/src/version.d.ts +1 -1
  22. package/package.json +15 -10
  23. package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
  24. package/src/Screens/Static/ResultScreen.tsx +5 -0
  25. package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
  26. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  27. package/src/Shared/Libs/mrz.utils.ts +704 -11
  28. package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
  29. package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
  30. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustchex/react-native-sdk",
3
- "version": "1.472.0",
3
+ "version": "1.475.1",
4
4
  "description": "Trustchex mobile app react native SDK for android or ios devices",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -60,7 +60,17 @@
60
60
  "@react-navigation/core": "7.12.4",
61
61
  "@react-navigation/native": "7.1.18",
62
62
  "@react-navigation/native-stack": "7.3.27",
63
- "@react-navigation/elements": "2.6.5"
63
+ "@react-navigation/elements": "2.6.5",
64
+ "lodash": "4.17.23",
65
+ "brace-expansion": "1.1.13",
66
+ "minimatch": "3.1.4",
67
+ "qs": "6.15.2",
68
+ "ip-address": "10.1.1",
69
+ "uuid": "11.1.1",
70
+ "yaml": "2.8.3",
71
+ "fast-uri": "3.1.2",
72
+ "flatted": "3.4.2",
73
+ "react-native-screens": "4.18.0"
64
74
  },
65
75
  "scripts": {
66
76
  "example": "yarn workspace @trustchex/react-native-sdk-example",
@@ -116,7 +126,6 @@
116
126
  "react-native-svg": "^15.12.0",
117
127
  "react-native-tts": "^4.1.1",
118
128
  "react-native-video": "^6.16.1",
119
- "react-native-vision-camera": "^4.7.1",
120
129
  "react-native-webrtc": "^124.0.0",
121
130
  "react-native-webview": "^13.15.0",
122
131
  "release-it": "^17.10.0",
@@ -129,6 +138,7 @@
129
138
  "buffer": "^6.0.3",
130
139
  "crypto-js": "^4.2.0",
131
140
  "elliptic": "^6.6.1",
141
+ "hash.js": "^1.1.7",
132
142
  "i18next": "^25.3.2",
133
143
  "jpeg2000": "^1.1.1",
134
144
  "mrz": "^5.0.1",
@@ -153,10 +163,8 @@
153
163
  "react-native-svg": ">=15.12.0",
154
164
  "react-native-tts": ">=4.1.1",
155
165
  "react-native-video": ">=6.16.1",
156
- "react-native-vision-camera": ">=4.7.1",
157
166
  "react-native-webrtc": ">=124.0.0",
158
- "react-native-webview": ">=13.15.0",
159
- "react-native-worklets-core": ">=1.6.2"
167
+ "react-native-webview": ">=13.15.0"
160
168
  },
161
169
  "peerDependenciesMeta": {
162
170
  "@react-navigation/native": {
@@ -210,9 +218,6 @@
210
218
  "react-native-video": {
211
219
  "optional": false
212
220
  },
213
- "react-native-vision-camera": {
214
- "optional": false
215
- },
216
221
  "react-native-webrtc": {
217
222
  "optional": false
218
223
  },
@@ -231,7 +236,7 @@
231
236
  "<rootDir>/lib/"
232
237
  ],
233
238
  "transformIgnorePatterns": [
234
- "node_modules/(?!(uuid|react-native|@react-native|react-native-device-info|react-native-get-random-values|react-native-fs)/)"
239
+ "node_modules/(?!(uuid|mrz|react-native|@react-native|react-native-device-info|react-native-get-random-values|react-native-fs)/)"
235
240
  ]
236
241
  },
237
242
  "commitlint": {
@@ -7,116 +7,108 @@ import {
7
7
  StatusBar,
8
8
  TouchableOpacity,
9
9
  type NativeSyntheticEvent,
10
- type LayoutChangeEvent,
11
10
  } from 'react-native';
12
11
  import { SafeAreaView } from 'react-native-safe-area-context';
13
12
  import {
14
13
  TrustchexCamera,
15
14
  type TrustchexCameraHandle,
16
15
  type Frame,
17
- type NativeTextBlock,
18
16
  } from '../../Shared/Components/TrustchexCamera';
19
- import mrzUtils from '../../Shared/Libs/mrz.utils';
17
+ import { MRZFrameAggregator } from '../../Shared/Libs/mrzFrameAggregator';
20
18
  import { useKeepAwake } from '../../Shared/Libs/native-keep-awake.utils';
21
19
 
22
- interface MrzOverlayBlock extends NativeTextBlock {
23
- isMrz: boolean;
20
+ // All per-frame display data lives in ONE state object so a processed frame
21
+ // triggers a SINGLE re-render instead of 7 separate setState calls.
22
+ interface MrzView {
23
+ raw: string;
24
+ voted: string;
25
+ valid: boolean;
26
+ frames: number;
27
+ stable: boolean;
24
28
  }
25
29
 
30
+ const EMPTY_VIEW: MrzView = {
31
+ raw: '',
32
+ voted: 'Voting…',
33
+ valid: false,
34
+ frames: 0,
35
+ stable: false,
36
+ };
37
+
38
+ // Process the live OCR stream at this cadence. The camera delivers frames faster
39
+ // than the MRZ validation/voting can run; gating here keeps the JS thread free to
40
+ // service touches (so the buttons stay responsive) while still converging fast.
41
+ const MRZ_PROCESS_INTERVAL_MS = 400;
42
+
26
43
  const MRZTestScreen = () => {
27
44
  useKeepAwake();
28
45
  const cameraRef = useRef<TrustchexCameraHandle>(null);
29
- const [mrzText, setMrzText] = useState<string>('Waiting for MRZ...');
30
- const [mrzRawText, setMrzRawText] = useState<string>('');
31
- const [mrzValid, setMrzValid] = useState<boolean>(false);
32
- const [rawHistory, setRawHistory] = useState<string[]>([]);
46
+
47
+ // Single consolidated view-state; one setState per processed frame.
48
+ const [view, setView] = useState<MrzView>(EMPTY_VIEW);
33
49
  const [isPaused, setIsPaused] = useState<boolean>(false);
34
- const [overlayBlocks, setOverlayBlocks] = useState<MrzOverlayBlock[]>([]);
35
- const [cameraLayout, setCameraLayout] = useState({ width: 1, height: 1 });
36
- const [frameSize, setFrameSize] = useState({ width: 1080, height: 1920 });
37
50
 
38
- const handleCameraLayout = useCallback((e: LayoutChangeEvent) => {
39
- const { width, height } = e.nativeEvent.layout;
40
- setCameraLayout({ width, height });
41
- }, []);
51
+ // Refs for values the frame handler reads but that must NOT re-create it:
52
+ // keeping handleFrame identity-stable avoids re-subscribing the camera and
53
+ // keeps it cheap.
54
+ const pausedRef = useRef(false);
55
+ const lastProcessedAt = useRef(0);
56
+ const aggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
42
57
 
43
58
  const handleFrame = useCallback(
44
59
  (event: NativeSyntheticEvent<{ frame: Frame }>) => {
45
- if (isPaused) {
46
- return;
47
- }
48
-
49
- const frame = event.nativeEvent.frame;
50
-
51
- if (frame.width && frame.height) {
52
- setFrameSize({ width: frame.width, height: frame.height });
53
- }
60
+ if (pausedRef.current) return;
54
61
 
55
- if (frame.textBlocks && frame.textBlocks.length > 0) {
56
- const frameHeight = frame.height;
57
- const bottomThreshold = frameHeight * 0.5;
58
- const blocks: MrzOverlayBlock[] = frame.textBlocks.map((block) => {
59
- const blockText = block.text || '';
60
- return {
61
- ...block,
62
- isMrz:
63
- blockText.includes('<') &&
64
- blockText.length >= 12 &&
65
- (block.blockFrame ? block.blockFrame.y > bottomThreshold : false),
66
- };
67
- });
68
- setOverlayBlocks(blocks);
69
- } else {
70
- setOverlayBlocks([]);
71
- }
62
+ // Throttle the ENTIRE handler. Drop frames that arrive inside the window
63
+ // before doing ANY work (no parsing, no setState) so touch events land.
64
+ const now = Date.now();
65
+ if (now - lastProcessedAt.current < MRZ_PROCESS_INTERVAL_MS) return;
72
66
 
73
- if (frame.resultText) {
74
- setRawHistory((prev) => {
75
- if (prev[0] === frame.resultText) {
76
- return prev;
77
- }
78
- const next = [frame.resultText, ...prev];
79
- return next.slice(0, 20);
80
- });
81
- }
82
-
83
- if (frame.resultText && frame.resultText.includes('<')) {
84
- // Extract MRZ-only text from detected blocks
85
- let mrzOnlyText = '';
86
- if (frame.textBlocks && frame.textBlocks.length > 0) {
87
- // Filter blocks that look like MRZ (contain < and are in bottom half of frame)
88
- const frameHeight = frame.height;
89
- const bottomThreshold = frameHeight * 0.5;
90
-
91
- const mrzBlocks = frame.textBlocks.filter((block) => {
92
- const blockText = block.text || '';
93
- const hasFillers = blockText.includes('<');
94
- const isInBottomArea = block.blockFrame
95
- ? block.blockFrame.y > bottomThreshold
96
- : false;
97
- const isLongEnough = blockText.length >= 12;
98
- return hasFillers && isInBottomArea && isLongEnough;
99
- });
67
+ const frame = event.nativeEvent.frame;
68
+ const resultText = frame.resultText;
69
+ if (!resultText || !resultText.includes('<')) return;
70
+ lastProcessedAt.current = now;
100
71
 
101
- if (mrzBlocks.length > 0) {
102
- mrzOnlyText = mrzBlocks.map((b) => b.text).join('\n');
103
- }
104
- }
72
+ // Collect every MRZ-looking block (contains a filler) regardless of
73
+ // position/length the band is often fragmented and may not sit in the
74
+ // bottom half. Reconstruction downstream isolates it from front-of-card text.
75
+ const mrzBlocks = (frame.textBlocks ?? []).filter((b) =>
76
+ (b.text || '').includes('<')
77
+ );
78
+ const mrzOnlyText = mrzBlocks.length
79
+ ? mrzBlocks.map((b) => b.text).join('\n')
80
+ : resultText;
105
81
 
106
- // Fallback to full text if no MRZ blocks detected
107
- if (!mrzOnlyText) {
108
- mrzOnlyText = frame.resultText;
109
- }
82
+ const bandHeight = mrzBlocks.length
83
+ ? Math.max(0, ...mrzBlocks.map((b) => b.blockFrame?.height ?? 0))
84
+ : 0;
85
+ const consensus = aggregator.current.addFrame({
86
+ text: `${mrzOnlyText}\n${resultText}`,
87
+ weight: bandHeight > 0 ? bandHeight : 1,
88
+ });
110
89
 
111
- const fixedMrz = mrzUtils.fixMRZ(mrzOnlyText);
112
- setMrzRawText(mrzOnlyText);
113
- setMrzText(fixedMrz);
114
- setMrzValid(mrzUtils.validateMRZWithCorrections(fixedMrz).valid);
115
- }
90
+ // One setState → one re-render.
91
+ setView({
92
+ raw: mrzOnlyText,
93
+ voted: consensus.mrz ?? `Voting… (${consensus.frames} frames)`,
94
+ valid: !!consensus.validation?.valid,
95
+ frames: consensus.frames,
96
+ stable: consensus.stable,
97
+ });
116
98
  },
117
- [isPaused]
99
+ []
118
100
  );
119
101
 
102
+ const togglePause = useCallback(() => {
103
+ pausedRef.current = !pausedRef.current;
104
+ setIsPaused(pausedRef.current);
105
+ }, []);
106
+
107
+ const resetVoting = useCallback(() => {
108
+ aggregator.current.reset();
109
+ setView(EMPTY_VIEW);
110
+ }, []);
111
+
120
112
  return (
121
113
  <View style={styles.container}>
122
114
  <StatusBar
@@ -124,7 +116,7 @@ const MRZTestScreen = () => {
124
116
  backgroundColor="transparent"
125
117
  translucent
126
118
  />
127
- <View style={styles.cameraWrapper} onLayout={handleCameraLayout}>
119
+ <View style={styles.cameraWrapper}>
128
120
  <TrustchexCamera
129
121
  ref={cameraRef}
130
122
  style={StyleSheet.absoluteFill}
@@ -134,78 +126,50 @@ const MRZTestScreen = () => {
134
126
  enableTextRecognition={true}
135
127
  enableBarcodeScanning={false}
136
128
  includeBase64={false}
137
- targetFps={10}
129
+ targetFps={5}
138
130
  onFrameAvailable={handleFrame}
139
131
  />
140
- {overlayBlocks.map((block, i) => {
141
- const bf = block.blockFrame;
142
- if (!bf) return null;
143
- const scaleX = cameraLayout.width / frameSize.width;
144
- const scaleY = cameraLayout.height / frameSize.height;
145
- return (
146
- <View
147
- key={i}
148
- style={[
149
- styles.boundingBox,
150
- block.isMrz ? styles.boundingBoxMrz : styles.boundingBoxText,
151
- {
152
- left: bf.x * scaleX,
153
- top: bf.y * scaleY,
154
- width: bf.width * scaleX,
155
- height: bf.height * scaleY,
156
- },
157
- ]}
158
- >
159
- {block.isMrz && (
160
- <View style={styles.blockLabel}>
161
- <Text style={styles.blockLabelText}>MRZ</Text>
162
- </View>
163
- )}
164
- </View>
165
- );
166
- })}
132
+
133
+ {/* Scan-frame guide: matches the native OCR crop region. Only the data
134
+ INSIDE this box is OCR'd — align the card's MRZ band here. */}
135
+ <View pointerEvents="none" style={styles.scanGuide}>
136
+ <Text style={styles.scanGuideLabel}>Align MRZ inside this frame</Text>
137
+ </View>
167
138
  </View>
168
139
 
169
140
  <SafeAreaView style={styles.mrzPanel} edges={['bottom']}>
170
141
  <ScrollView style={styles.scrollView}>
171
142
  <View style={styles.panelContent}>
172
- <TouchableOpacity
173
- style={styles.pauseButton}
174
- onPress={() => setIsPaused(!isPaused)}
175
- >
176
- <Text style={styles.pauseButtonText}>
177
- {isPaused ? 'Resume Processing' : 'Pause Processing'}
178
- </Text>
179
- </TouchableOpacity>
143
+ <View style={styles.buttonRow}>
144
+ <TouchableOpacity
145
+ style={styles.pauseButton}
146
+ onPress={togglePause}
147
+ >
148
+ <Text style={styles.pauseButtonText}>
149
+ {isPaused ? 'Resume Processing' : 'Pause Processing'}
150
+ </Text>
151
+ </TouchableOpacity>
152
+ <TouchableOpacity
153
+ style={styles.pauseButton}
154
+ onPress={resetVoting}
155
+ >
156
+ <Text style={styles.pauseButtonText}>Reset Voting</Text>
157
+ </TouchableOpacity>
158
+ </View>
180
159
  <Text style={styles.title}>MRZ Text Read</Text>
181
- <Text style={styles.sectionTitle}>MRZ Raw Text</Text>
182
- <Text style={styles.mrzText}>
183
- {mrzRawText
184
- ? mrzRawText
185
- .split('\n')
186
- .map(
187
- (line, i) =>
188
- `Line ${i + 1}: ${line} (${line.length} chars)`
189
- )
190
- .join('\n')
191
- : 'No MRZ raw text'}
160
+
161
+ <Text style={styles.sectionTitle}>
162
+ ★ Multi-frame Voted MRZ — {view.frames} frames
163
+ {view.valid ? ' · VALID' : ''}
164
+ {view.stable ? ' · STABLE' : ''}
192
165
  </Text>
193
- <Text style={styles.sectionTitle}>MRZ Corrected Text</Text>
194
- <Text style={[styles.mrzText, mrzValid ? styles.mrzValid : null]}>
195
- {mrzText
196
- .split('\n')
197
- .map(
198
- (line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`
199
- )
200
- .join('\n')}
166
+ <Text style={[styles.mrzText, view.valid ? styles.mrzValid : null]}>
167
+ {formatLines(view.voted)}
201
168
  </Text>
202
- <Text style={styles.sectionTitle}>Raw Text History</Text>
169
+
170
+ <Text style={styles.sectionTitle}>MRZ Raw Text (this frame)</Text>
203
171
  <Text style={styles.mrzText}>
204
- {rawHistory.length > 0
205
- ? rawHistory
206
- .map((text, i) => `#${i + 1}: ${text.replace(/\n/g, ' ')}`)
207
- .join('\n')
208
- : 'No history'}
172
+ {view.raw ? formatLines(view.raw) : 'No MRZ raw text'}
209
173
  </Text>
210
174
  </View>
211
175
  </ScrollView>
@@ -214,6 +178,13 @@ const MRZTestScreen = () => {
214
178
  );
215
179
  };
216
180
 
181
+ /** Render MRZ text as "Line N: <chars> (N chars)" rows. */
182
+ const formatLines = (text: string): string =>
183
+ text
184
+ .split('\n')
185
+ .map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`)
186
+ .join('\n');
187
+
217
188
  const styles = StyleSheet.create({
218
189
  container: {
219
190
  flex: 1,
@@ -223,31 +194,26 @@ const styles = StyleSheet.create({
223
194
  flex: 2,
224
195
  position: 'relative',
225
196
  },
226
- boundingBox: {
197
+ scanGuide: {
227
198
  position: 'absolute',
228
- borderWidth: 1.5,
229
- },
230
- boundingBoxText: {
231
- borderColor: 'rgba(255, 255, 255, 0.25)',
232
- },
233
- boundingBoxMrz: {
234
- borderColor: '#FFA500',
199
+ // Mirrors the native OCR crop: top 25% / bottom 12% / 4% side margins.
200
+ top: '25%',
201
+ bottom: '12%',
202
+ left: '4%',
203
+ right: '4%',
235
204
  borderWidth: 2,
236
- backgroundColor: 'rgba(255, 165, 0, 0.07)',
237
- },
238
- blockLabel: {
239
- position: 'absolute',
240
- top: -14,
241
- left: 0,
242
- backgroundColor: '#FFA500',
243
- paddingHorizontal: 4,
244
- paddingVertical: 1,
245
- borderRadius: 2,
205
+ borderColor: '#00E5FF',
206
+ borderRadius: 6,
207
+ justifyContent: 'flex-start',
208
+ alignItems: 'center',
246
209
  },
247
- blockLabelText: {
248
- color: '#000000',
249
- fontSize: 8,
210
+ scanGuideLabel: {
211
+ color: '#00E5FF',
212
+ fontSize: 10,
250
213
  fontWeight: 'bold',
214
+ marginTop: -16,
215
+ backgroundColor: 'rgba(0,0,0,0.5)',
216
+ paddingHorizontal: 4,
251
217
  },
252
218
  mrzPanel: {
253
219
  flex: 1,
@@ -261,6 +227,11 @@ const styles = StyleSheet.create({
261
227
  panelContent: {
262
228
  padding: 10,
263
229
  },
230
+ buttonRow: {
231
+ flexDirection: 'row',
232
+ justifyContent: 'center',
233
+ gap: 8,
234
+ },
264
235
  pauseButton: {
265
236
  alignSelf: 'center',
266
237
  borderWidth: 1,
@@ -100,10 +100,15 @@ const ResultScreen = () => {
100
100
 
101
101
  const formatGender = useCallback(
102
102
  (code?: string | null) => {
103
+ // The eID/NFC path stores sex as "M"/"F" (ICAO getStrCode), while the
104
+ // camera MRZ path (the `mrz` npm package) stores "male"/"female". Accept
105
+ // both so the ID scan shows the gender instead of "unspecified".
103
106
  switch (code?.toUpperCase()) {
104
107
  case 'M':
108
+ case 'MALE':
105
109
  return t('eidScannerScreen.genderMale');
106
110
  case 'F':
111
+ case 'FEMALE':
107
112
  return t('eidScannerScreen.genderFemale');
108
113
  default:
109
114
  return t('eidScannerScreen.genderUnspecified');
@@ -23,6 +23,7 @@ import {
23
23
  import { NativeModules } from 'react-native';
24
24
  import PermissionManager from '../Libs/permissions.utils';
25
25
  import mrzUtils from '../Libs/mrz.utils';
26
+ import { MRZFrameAggregator } from '../Libs/mrzFrameAggregator';
26
27
  import { useKeepAwake } from '../Libs/native-keep-awake.utils';
27
28
  import { useIsFocused } from '@react-navigation/native';
28
29
  import { useTranslation } from 'react-i18next';
@@ -144,6 +145,10 @@ const IdentityDocumentCamera = ({
144
145
  const lastValidMRZText = useRef<string | null>(null);
145
146
  const lastValidMRZFields = useRef<any>(null);
146
147
  const validMRZConsecutiveCount = useRef(0);
148
+ // Multi-frame per-character voting: fuses OCR across frames so a single-frame
149
+ // misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
150
+ // resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
151
+ const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
147
152
 
148
153
  // Document type stability tracking - require consistent detections from good quality frames
149
154
  const lastDetectedDocType = useRef<
@@ -277,6 +282,7 @@ const IdentityDocumentCamera = ({
277
282
  lastValidMRZText.current = null;
278
283
  lastValidMRZFields.current = null;
279
284
  validMRZConsecutiveCount.current = 0;
285
+ mrzAggregator.current.reset();
280
286
  cachedBarcode.current = null;
281
287
  isCompletionCallbackInvoked.current = false;
282
288
  isStepTransitionInProgress.current = false;
@@ -601,6 +607,8 @@ const IdentityDocumentCamera = ({
601
607
  if (nextStepType === 'SCAN_ID_BACK') {
602
608
  lastValidMRZText.current = null;
603
609
  lastValidMRZFields.current = null;
610
+ // New MRZ expected on the back — start voting fresh.
611
+ mrzAggregator.current.reset();
604
612
  }
605
613
  validMRZConsecutiveCount.current = 0;
606
614
  cachedBarcode.current = null; // Clear cached barcode on step change
@@ -848,14 +856,43 @@ const IdentityDocumentCamera = ({
848
856
  // Prefer MRZ-only text if available (from detected MRZ blocks),
849
857
  // otherwise fall back to all text (for backward compatibility)
850
858
  const textForValidation = scannedText?.mrzOnlyText || text;
859
+
860
+ // Multi-frame per-character voting: feed this frame's OCR into the
861
+ // aggregator (weighted by the MRZ band's pixel height as a sharpness/
862
+ // closeness proxy — a taller band means more pixels per character, which
863
+ // ML Kit reads more reliably) and prefer the fused consensus. This outvotes
864
+ // single-frame OCR errors (B↔8, S↔5, <↔K, dropped/inserted digit) instead
865
+ // of letting one noisy frame reset progress.
866
+ const mrzBandHeight = scannedText?.blocks?.length
867
+ ? Math.max(
868
+ 0,
869
+ ...scannedText.blocks.map((b) => b.blockFrame?.height ?? 0)
870
+ )
871
+ : 0;
872
+ const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
873
+ const consensus = mrzAggregator.current.addFrame({
874
+ text: textForValidation,
875
+ weight: frameWeight,
876
+ });
877
+
878
+ // Use the fused consensus when it is valid; otherwise fall back to a
879
+ // single-frame validation so document-type detection still works on the
880
+ // early frames. Only run the (expensive) single-frame pass when the
881
+ // consensus has NOT yet validated — once it has, the per-frame validation
882
+ // is redundant work that would needlessly load the JS thread.
851
883
  const mrzValidationResult =
852
- mrzUtils.validateMRZWithCorrections(textForValidation);
884
+ consensus.validation?.valid && consensus.validation.fields
885
+ ? consensus.validation
886
+ : mrzUtils.validateMRZWithCorrections(textForValidation);
853
887
  const parsedMRZData = {
854
888
  valid: mrzValidationResult.valid,
855
889
  fields: mrzValidationResult.fields || null,
856
890
  };
857
- // Extract raw MRZ lines from text if validation succeeded
858
- const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
891
+ // Raw MRZ lines: prefer the fused/corrected consensus when valid, else the
892
+ // lightweight per-frame clean. correctedMrz is the noise-free recovered MRZ.
893
+ const mrzText = parsedMRZData.valid
894
+ ? (mrzValidationResult.correctedMrz ?? mrzUtils.fixMRZ(text))
895
+ : null;
859
896
 
860
897
  // CRITICAL: Only detect document type during initial scan step
861
898
  // For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
@@ -1006,9 +1043,12 @@ const IdentityDocumentCamera = ({
1006
1043
 
1007
1044
  // Check if we have enough consistent valid reads
1008
1045
  const mrzStableAndValid =
1009
- validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1010
- parsedMRZData?.valid === true &&
1011
- areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
1046
+ // Either: the multi-frame voted consensus has converged (valid + stable),
1047
+ (consensus.stable && parsedMRZData?.valid === true) ||
1048
+ // or the legacy N-identical-reads path is satisfied.
1049
+ (validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1050
+ parsedMRZData?.valid === true &&
1051
+ areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields));
1012
1052
 
1013
1053
  // ============================================================================
1014
1054
  // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card