@sanctum-key/react-native-sdk 1.0.15 → 1.0.17

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.
@@ -22,50 +22,6 @@ interface ApiVerificationResponse {
22
22
  [key: string]: any;
23
23
  }
24
24
 
25
- function getDistance(p1: number[], p2: number[]): number {
26
- return Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2));
27
- }
28
-
29
- function getPolygonArea(points: number[][]): number {
30
- const n = points.length;
31
- let area = 0;
32
- for (let i = 0; i < n; i++) {
33
- const j = (i + 1) % n;
34
- area += points[i][0] * points[j][1];
35
- area -= points[j][0] * points[i][1];
36
- }
37
- return Math.abs(area) / 2;
38
- }
39
-
40
- function assertCardNotTooFar(points: number[][]): void {
41
- if (!points || points.length < 3) return;
42
-
43
- const longestCardEdge = Math.max(
44
- getDistance(points[0], points[1]),
45
- getDistance(points[1], points[2]),
46
- getDistance(points[2], points[3] ?? points[0]),
47
- );
48
-
49
- const cardArea = getPolygonArea(points);
50
-
51
- logger.log(
52
- `Card distance check – longest edge: ${Math.round(longestCardEdge)} px, ` +
53
- `OBB area: ${Math.round(cardArea)} px²`
54
- );
55
-
56
- if (longestCardEdge < 700) {
57
- logger.log(`🛑 Rejected: longest edge too short (${Math.round(longestCardEdge)} px < 700 px)`);
58
- throw new Error('TOO_FAR_AWAY');
59
- }
60
-
61
- if (cardArea < 400_000) {
62
- logger.log(`🛑 Rejected: card area too small (${Math.round(cardArea)} px² < 400 000 px²)`);
63
- throw new Error('TOO_FAR_AWAY');
64
- }
65
- }
66
-
67
- // ─── frontVerification ────────────────────────────────────────────────────────
68
-
69
25
  export async function frontVerification(
70
26
  result: {
71
27
  path?: string,
@@ -82,8 +38,8 @@ export async function frontVerification(
82
38
  console.log("Front verification START", JSON.stringify({ result }, null, 2));
83
39
  logger.log("Front verification", JSON.stringify({ result }, null, 2));
84
40
 
85
- const authMethods = Array.isArray(result.regionMapping?.authMethod)
86
- ? result.regionMapping.authMethod.map(m => String(m).toUpperCase())
41
+ const authMethods = Array.isArray(result.regionMapping?.authMethod)
42
+ ? result.regionMapping.authMethod.map(m => String(m).toUpperCase())
87
43
  : [];
88
44
  const hasMrz = authMethods.some(m => m.includes('MRZ'));
89
45
 
@@ -130,6 +86,7 @@ export async function frontVerification(
130
86
  let points: number[][] | null = null;
131
87
  let isCardInFrame = true;
132
88
  let hasCroppedSides = false;
89
+ console.log(points)
133
90
 
134
91
  if (cardData) {
135
92
  points = cardData.obb;
@@ -140,14 +97,10 @@ export async function frontVerification(
140
97
  }
141
98
 
142
99
  if (!isCardInFrame || hasCroppedSides) {
143
- logger.log(`Front Framing failed. Cropped sides detected.`);
100
+ logger.log(`Front Framing failed. Cropped sides detected by backend.`);
144
101
  throw new Error('CARD_NOT_FULLY_IN_FRAME');
145
102
  }
146
103
 
147
- if (points && points.length >= 3) {
148
- assertCardNotTooFar(points);
149
- }
150
-
151
104
  const obbConfidence = getObbConfidence(detected.card_obb);
152
105
  if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
153
106
  throw new Error('Carte non entièrement visible. Positionnez toute la carte dans le cadre.');
@@ -174,11 +127,11 @@ export async function frontVerification(
174
127
  postfix: result?.currentSide || 'front',
175
128
  token: token,
176
129
  template_path: result?.templatePath || '',
177
- mrz_type: result?.mrzType || 'TD1'
130
+ mrz_type: result?.mrzType || 'TD1'
178
131
  },
179
132
  env
180
133
  );
181
-
134
+
182
135
  mrz = typeof mrzResponse === 'string' ? JSON.parse(mrzResponse) : mrzResponse;
183
136
 
184
137
  if (mrz?.parsed_data?.status === 'FAILURE' || mrz?.success === false) {
@@ -186,7 +139,7 @@ export async function frontVerification(
186
139
  throw new Error(`MRZ illisible: ${mrz?.parsed_data?.status_message || 'Veuillez nettoyer l\'objectif et réessayer'}`);
187
140
  }
188
141
  } catch (err: any) {
189
- logger.error("MRZ Extraction Error:", err);
142
+ logger.log("MRZ Extraction Error:", err);
190
143
  throw new Error(err?.message || "Erreur lors de l'extraction MRZ. Veuillez reprendre la photo.");
191
144
  }
192
145
  }
@@ -194,12 +147,11 @@ export async function frontVerification(
194
147
  return { ...detected, croppedBase64, bbox, ...(mrz ? { mrz } : {}) };
195
148
 
196
149
  } catch (e: any) {
197
- logger.error('Error front verification:', e?.message);
150
+ logger.log('Error front verification:', e?.message);
198
151
  throw new Error(e?.message || 'Erreur de détection du visage');
199
152
  }
200
153
  }
201
154
 
202
-
203
155
  export async function backVerification(
204
156
  result: {
205
157
  path?: string,
@@ -218,8 +170,8 @@ export async function backVerification(
218
170
  logger.log("result.regionMapping", result.regionMapping, result.currentSide, result.code);
219
171
  logger.log('result object', result)
220
172
 
221
- const authMethods = Array.isArray(result.regionMapping?.authMethod)
222
- ? result.regionMapping.authMethod.map(m => String(m).toUpperCase())
173
+ const authMethods = Array.isArray(result.regionMapping?.authMethod)
174
+ ? result.regionMapping.authMethod.map(m => String(m).toUpperCase())
223
175
  : [];
224
176
 
225
177
  const hasMrz = authMethods.some(m => m.includes('MRZ'))
@@ -285,6 +237,7 @@ export async function backVerification(
285
237
  let points: number[][] | null = null;
286
238
  let isCardInFrame = false;
287
239
  let hasCroppedSides = true;
240
+ console.log(points)
288
241
 
289
242
  if (cardData.obb) {
290
243
  points = cardData.obb;
@@ -296,25 +249,14 @@ export async function backVerification(
296
249
  points = cardData[0];
297
250
  isCardInFrame = true;
298
251
  hasCroppedSides = false;
299
- if (points && points.length === 4) {
300
- const minX = Math.min(...points.map((p: number[]) => p[0]));
301
- const minY = Math.min(...points.map((p: number[]) => p[1]));
302
- if (minX <= 15 || minY <= 15) {
303
- isCardInFrame = false;
304
- hasCroppedSides = true;
305
- }
306
- }
252
+ // 🚨 ARTIFICIAL CHECK REMOVED HERE
307
253
  }
308
254
 
309
255
  if (!isCardInFrame || hasCroppedSides) {
310
- logger.log(`Back Framing failed. Coordinates hit image boundary.`);
256
+ logger.log(`Back Framing failed. Backend reported document out of bounds.`);
311
257
  throw new Error('CARD_NOT_FULLY_IN_FRAME');
312
258
  }
313
259
 
314
- if (points && points.length >= 3) {
315
- assertCardNotTooFar(points);
316
- }
317
-
318
260
  const obbConf = getObbConfidence(templateResponse?.card_obb);
319
261
  if (obbConf !== null && obbConf < OBB_CONFIDENCE_THRESHOLD) {
320
262
  throw new Error('Carte non entièrement visible. Positionnez toute la carte dans le cadre.');
@@ -354,9 +296,9 @@ export async function backVerification(
354
296
  if (mrz?.parsed_data?.status === 'FAILURE' || mrz?.success === false) {
355
297
  throw new Error(mrz?.parsed_data?.status_message || 'Lecture MRZ échouée');
356
298
  }
357
-
299
+
358
300
  extractionResult.mrz = mrz; // Attaching securely to result object
359
-
301
+
360
302
  } catch (mrzError: any) {
361
303
  logger.log(`MRZ échoué: ${mrzError?.message}`);
362
304
  // If MRZ fails, we MUST throw an error so the user is forced to retake it!
@@ -394,7 +336,7 @@ export async function backVerification(
394
336
  };
395
337
 
396
338
  } catch (e: any) {
397
- if (e?.message === 'CARD_NOT_FULLY_IN_FRAME' || e?.message === 'TOO_FAR_AWAY') throw e;
339
+ if (e?.message === 'CARD_NOT_FULLY_IN_FRAME') throw e;
398
340
  throw new Error(e?.message || 'Erreur de détection des données');
399
341
  }
400
342
  }
@@ -439,6 +381,7 @@ export async function checkTemplateType(
439
381
  let points: number[][] | null = null;
440
382
  let isCardInFrame = false;
441
383
  let hasCroppedSides = true;
384
+ console.log(points)
442
385
 
443
386
  if (cardData.obb) {
444
387
  points = cardData.obb;
@@ -450,25 +393,14 @@ export async function checkTemplateType(
450
393
  points = cardData[0];
451
394
  isCardInFrame = true;
452
395
  hasCroppedSides = false;
453
- if (points && points.length === 4) {
454
- const minX = Math.min(...points.map((p: number[]) => p[0]));
455
- const minY = Math.min(...points.map((p: number[]) => p[1]));
456
- if (minX <= 15 || minY <= 15) {
457
- isCardInFrame = false;
458
- hasCroppedSides = true;
459
- }
460
- }
396
+ // 🚨 ARTIFICIAL CHECK REMOVED HERE
461
397
  }
462
398
 
463
399
  if (!isCardInFrame || hasCroppedSides) {
464
- logger.log(`Template Framing failed.`);
400
+ logger.log(`Template Framing failed by backend check.`);
465
401
  throw new Error('CARD_NOT_FULLY_IN_FRAME');
466
402
  }
467
403
 
468
- if (points && points.length >= 3) {
469
- assertCardNotTooFar(points);
470
- }
471
-
472
404
  const LPIPS_THRESHOLD = 0.75;
473
405
  if (templateType.lpips_score !== undefined && templateType.lpips_score > LPIPS_THRESHOLD) {
474
406
  logger.log(`🛑 Country Mismatch! LPIPS: ${templateType.lpips_score}`);
@@ -478,8 +410,8 @@ export async function checkTemplateType(
478
410
  logger.log("templateType result", JSON.stringify(truncateFields(templateType), null, 2));
479
411
  return templateType;
480
412
  } catch (e: any) {
481
- logger.error('Errorrr checking template type:', JSON.stringify(errorMessage(e), null, 2));
482
- if (e?.message === 'CARD_NOT_FULLY_IN_FRAME' || e?.message === 'TOO_FAR_AWAY' || e?.message?.includes('ne correspond pas')) throw e;
413
+ logger.log('Errorrr checking template type:', JSON.stringify(errorMessage(e), null, 2));
414
+ if (e?.message === 'CARD_NOT_FULLY_IN_FRAME' || e?.message?.includes('ne correspond pas')) throw e;
483
415
  throw new Error(e?.message || 'Erreur de vérification du template');
484
416
  }
485
417
  }
@@ -1,24 +1,18 @@
1
- import { Image as RNImage, Platform } from 'react-native';
1
+ import { Platform } from 'react-native';
2
2
  import * as ImageManipulator from "expo-image-manipulator";
3
3
 
4
4
 
5
5
  type Point = [number, number];
6
6
 
7
- /** OBB confidence below this = card not fully in frame; don't crop, give user feedback. */
8
7
  export const OBB_CONFIDENCE_THRESHOLD = 0.85;
9
8
 
10
- /**
11
- * card_obb format from API:
12
- * - Legacy: [ [ [p1,p2,p3,p4], confidence ] ]
13
- * - New: [ { obb: [p1,p2,p3,p4], confidence, card_in_frame?, cropped_sides? } ]
14
- * Returns confidence in [0,1] or null if not present.
15
- */
9
+
16
10
  export function getObbConfidence(cardObb: any): number | null {
17
11
  if (!cardObb || !Array.isArray(cardObb) || cardObb.length === 0) return null;
18
12
  const first = cardObb[0];
19
13
 
20
14
  // New format: first is an object with a confidence field
21
- if (first && typeof first === 'object' && 'confidence' in first) {
15
+ if (first && typeof first === 'object' && !Array.isArray(first) && 'confidence' in first) {
22
16
  const score = (first as any).confidence;
23
17
  return typeof score === 'number' ? score : null;
24
18
  }
@@ -36,14 +30,12 @@ export function getObbConfidence(cardObb: any): number | null {
36
30
  export function getCardInFrame(cardObb: any): boolean | null {
37
31
  if (!cardObb) return null;
38
32
 
39
- // card_obb can be an array of objects or a single object
40
33
  const first = Array.isArray(cardObb) ? cardObb[0] : cardObb;
41
34
 
42
35
  if (first && typeof first === 'object' && 'card_in_frame' in first) {
43
36
  const isCardInFrame = first.card_in_frame === true;
44
37
  const hasCroppedSides = Array.isArray(first.cropped_sides) && first.cropped_sides.length > 0;
45
38
 
46
- // If the API explicitly says it's not in frame, OR if it lists cropped edges, reject it.
47
39
  if (!isCardInFrame || hasCroppedSides) {
48
40
  if (hasCroppedSides) {
49
41
  console.warn(`Card is cropped on sides: ${first.cropped_sides.join(', ')}`);
@@ -51,11 +43,9 @@ export function getCardInFrame(cardObb: any): boolean | null {
51
43
  return false;
52
44
  }
53
45
 
54
- // If we made it here, card_in_frame is true AND cropped_sides is empty.
55
46
  return true;
56
47
  }
57
48
 
58
- // Backward compatibility check
59
49
  if (cardObb && typeof cardObb === 'object' && 'card_in_frame' in cardObb) {
60
50
  const value = (cardObb as any).card_in_frame;
61
51
  return typeof value === 'boolean' ? value : null;
@@ -64,6 +54,25 @@ export function getCardInFrame(cardObb: any): boolean | null {
64
54
  return null;
65
55
  }
66
56
 
57
+
58
+ function extractObbPoints(cardObb: any): Point[] | null {
59
+ if (!cardObb) return null;
60
+ const first = Array.isArray(cardObb) ? cardObb[0] : cardObb;
61
+
62
+ // New format: { obb: [...], confidence, ... }
63
+ if (first && typeof first === 'object' && !Array.isArray(first) && 'obb' in first) {
64
+ const pts = (first as any).obb;
65
+ if (Array.isArray(pts) && pts.length === 4) return pts as Point[];
66
+ }
67
+
68
+ // Legacy format: [ [p1,p2,p3,p4], confidence ]
69
+ if (Array.isArray(first) && Array.isArray(first[0]) && first[0].length === 4) {
70
+ return first[0] as Point[];
71
+ }
72
+
73
+ return null;
74
+ }
75
+
67
76
  // Compute axis-aligned bounding box from oriented quadrilateral
68
77
  function computeAabb(points: Point[]) {
69
78
  const xs = points.map(p => p[0]);
@@ -75,7 +84,6 @@ function computeAabb(points: Point[]) {
75
84
  return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
76
85
  }
77
86
 
78
- // Web-only crop using Canvas; returns dataURL (base64)
79
87
  async function cropWeb(uri: string, points: Point[]): Promise<string> {
80
88
  return new Promise((resolve, reject) => {
81
89
  const img = new Image();
@@ -89,7 +97,7 @@ async function cropWeb(uri: string, points: Point[]): Promise<string> {
89
97
  if (!ctx) return reject(new Error('Canvas context not available'));
90
98
  ctx.drawImage(
91
99
  img,
92
- minX, minY, width, height, // Source
100
+ minX, minY, width, height, // Source
93
101
  0, 0, canvas.width, canvas.height // Destination
94
102
  );
95
103
  resolve(canvas.toDataURL('image/jpeg', 0.92));
@@ -99,15 +107,17 @@ async function cropWeb(uri: string, points: Point[]): Promise<string> {
99
107
  });
100
108
  }
101
109
 
102
- // Fallback: return original for native
103
110
  export async function cropByObb(uri: string, cardObb: any): Promise<{ base64?: string; bbox?: { minX: number; minY: number; width: number; height: number } }> {
104
111
  try {
105
112
  const confidence = getObbConfidence(cardObb);
106
113
  if (confidence !== null && confidence < OBB_CONFIDENCE_THRESHOLD) {
107
114
  return {};
108
115
  }
109
- const first = Array.isArray(cardObb) ? cardObb[0] : null;
110
- const points = Array.isArray(first?.[0]) ? (first[0] as Point[]) : null;
116
+
117
+ // FIX: Use extractObbPoints() which handles both legacy and new API formats.
118
+ // The old code used `Array.isArray(first?.[0]) ? first[0] : null`, which
119
+ // returned null for new-format responses like [{obb:[...], confidence}].
120
+ const points = extractObbPoints(cardObb);
111
121
  if (!points || points.length !== 4) return {};
112
122
 
113
123
  const { minX, minY, width, height } = computeAabb(points);
@@ -124,28 +134,58 @@ export async function cropByObb(uri: string, cardObb: any): Promise<{ base64?: s
124
134
  }
125
135
 
126
136
 
137
+ async function normalizeOrientationAndGetDimensions(
138
+ uri: string
139
+ ): Promise<{ normalizedUri: string; width: number; height: number }> {
140
+ // Web: canvas already handles EXIF; skip normalization
141
+ if (Platform.OS === 'web') {
142
+ return new Promise((resolve, reject) => {
143
+ const img = new Image();
144
+ img.onload = () => resolve({ normalizedUri: uri, width: img.naturalWidth, height: img.naturalHeight });
145
+ img.onerror = reject;
146
+ img.src = uri;
147
+ });
148
+ }
149
+
150
+ // Native: re-encode to bake EXIF rotation into actual pixels
151
+ const result = await ImageManipulator.manipulateAsync(
152
+ uri,
153
+ [], // No transforms — just re-encode to normalize
154
+ { compress: 1, format: ImageManipulator.SaveFormat.JPEG }
155
+ );
156
+ return { normalizedUri: result.uri, width: result.width, height: result.height };
157
+ }
158
+
159
+
127
160
  export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tolerance: number = 0.02): Promise<string> {
128
161
  return new Promise<string>((resolve) => {
129
162
  let isResolved = false;
130
163
 
131
- // Safety Timeout (10 seconds)
164
+ // Safety Timeout (15 seconds — slightly longer to account for normalization step)
132
165
  const timeout = setTimeout(() => {
133
166
  if (!isResolved) {
134
167
  isResolved = true;
135
168
  console.warn("Crop timeout, returning original.");
136
169
  resolve(uri);
137
170
  }
138
- }, 10000);
171
+ }, 15000);
172
+
173
+ (async () => {
174
+ try {
175
+ // STEP 1: Normalize EXIF orientation.
176
+ // This is the core fix — on Android the raw photo pixels may be stored
177
+ // rotated. After this call, normalizedUri has pixels matching the on-screen
178
+ // orientation and actualWidth/actualHeight are the display-space dimensions.
179
+ const { normalizedUri, width: actualWidth, height: actualHeight } =
180
+ await normalizeOrientationAndGetDimensions(uri);
139
181
 
140
- try {
141
- RNImage.getSize(uri, async (actualWidth, actualHeight) => {
142
182
  if (isResolved) return;
143
183
 
144
- // 1. Detect if bbox is percentage (e.g., 0.15) or absolute pixels (e.g., 800)
145
- // Since we pass UI percentages, this will be true.
184
+ // STEP 2: Resolve bbox to pixel coordinates.
185
+ // Detect if bbox is percentage (e.g., 0.90) or absolute pixels (e.g., 800).
146
186
  const isPercentageBased = bbox.width <= 1.5 && bbox.height <= 1.5;
147
187
 
148
- let pixelMinX, pixelMinY, pixelWidth, pixelHeight;
188
+ let pixelMinX: number, pixelMinY: number, pixelWidth: number, pixelHeight: number;
149
189
 
150
190
  if (isPercentageBased) {
151
191
  pixelMinX = bbox.minX * actualWidth;
@@ -153,18 +193,16 @@ export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tol
153
193
  pixelWidth = bbox.width * actualWidth;
154
194
  pixelHeight = bbox.height * actualHeight;
155
195
  } else {
156
- // Fallback if backend pixels somehow bypass the check
157
196
  pixelMinX = bbox.minX;
158
197
  pixelMinY = bbox.minY;
159
198
  pixelWidth = bbox.width;
160
199
  pixelHeight = bbox.height;
161
200
  }
162
201
 
163
- // 2. Add Tolerance (Expands the box slightly to avoid cutting off physical card edges)
202
+ // STEP 3: Add tolerance and clamp to image bounds.
164
203
  const toleranceX = pixelWidth * tolerance;
165
204
  const toleranceY = pixelHeight * tolerance;
166
205
 
167
- // Clamp values to ensure we don't try to crop outside the image bounds
168
206
  const newMinX = Math.max(0, pixelMinX - toleranceX);
169
207
  const newMinY = Math.max(0, pixelMinY - toleranceY);
170
208
  const newMaxX = Math.min(actualWidth, pixelMinX + pixelWidth + toleranceX);
@@ -173,19 +211,18 @@ export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tol
173
211
  const finalCrop = {
174
212
  originX: Math.floor(newMinX),
175
213
  originY: Math.floor(newMinY),
176
- width: Math.floor(newMaxX - newMinX),
177
- height: Math.floor(newMaxY - newMinY),
214
+ width: Math.max(1, Math.floor(newMaxX - newMinX)),
215
+ height: Math.max(1, Math.floor(newMaxY - newMinY)),
178
216
  };
179
217
 
180
- // 3. Perform Final Crop
218
+ // STEP 4: Perform final crop on the already-normalized image.
181
219
  try {
182
- // @ts-ignore - Required for utility functions outside React components
183
220
  const result = await ImageManipulator.manipulateAsync(
184
- uri,
221
+ normalizedUri,
185
222
  [{ crop: finalCrop }],
186
223
  { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
187
224
  );
188
-
225
+
189
226
  if (!isResolved) {
190
227
  isResolved = true;
191
228
  clearTimeout(timeout);
@@ -195,23 +232,79 @@ export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tol
195
232
  if (!isResolved) {
196
233
  isResolved = true;
197
234
  clearTimeout(timeout);
198
- console.error("ImageManipulator failed, returning raw image", err);
199
- resolve(uri);
235
+ console.error("ImageManipulator crop failed, returning normalized image", err);
236
+
237
+ resolve(normalizedUri);
200
238
  }
201
239
  }
202
- }, () => {
240
+ } catch (e) {
203
241
  if (!isResolved) {
204
242
  isResolved = true;
205
243
  clearTimeout(timeout);
244
+ console.error("normalizeOrientationAndGetDimensions failed, returning original", e);
206
245
  resolve(uri);
207
246
  }
208
- });
209
- } catch (e) {
247
+ }
248
+ })();
249
+ });
250
+ }
251
+
252
+ export async function cropToCenterScanArea(
253
+ uri: string,
254
+ scanWidthRatio: number = 0.91, // Match Kotlin's 0.91f
255
+ scanAreaAspectRatio: number = 1.59 // Standard ID Card ratio
256
+ ): Promise<string> {
257
+ return new Promise<string>((resolve) => {
258
+ let isResolved = false;
259
+
260
+ const timeout = setTimeout(() => {
210
261
  if (!isResolved) {
211
262
  isResolved = true;
212
- clearTimeout(timeout);
213
263
  resolve(uri);
214
264
  }
215
- }
265
+ }, 10000);
266
+
267
+ (async () => {
268
+ try {
269
+ // 1. Use Claude's brilliant EXIF normalization first!
270
+ const { normalizedUri, width: actualWidth, height: actualHeight } =
271
+ await normalizeOrientationAndGetDimensions(uri);
272
+
273
+ if (isResolved) return;
274
+
275
+ // 2. Apply the Kotlin mathematical centering
276
+ const scanWidth = actualWidth * scanWidthRatio;
277
+ const scanHeight = scanWidth / scanAreaAspectRatio;
278
+ const left = (actualWidth - scanWidth) / 2;
279
+ const top = (actualHeight - scanHeight) / 2;
280
+
281
+ const finalCrop = {
282
+ originX: Math.max(0, Math.floor(left)),
283
+ originY: Math.max(0, Math.floor(top)),
284
+ width: Math.max(1, Math.floor(scanWidth)),
285
+ height: Math.max(1, Math.floor(scanHeight)),
286
+ };
287
+
288
+ // 3. Perform the final centered crop
289
+ const result = await ImageManipulator.manipulateAsync(
290
+ normalizedUri,
291
+ [{ crop: finalCrop }],
292
+ { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
293
+ );
294
+
295
+ if (!isResolved) {
296
+ isResolved = true;
297
+ clearTimeout(timeout);
298
+ resolve(result.uri);
299
+ }
300
+ } catch (err) {
301
+ if (!isResolved) {
302
+ isResolved = true;
303
+ clearTimeout(timeout);
304
+ console.error("Center crop failed, returning original", err);
305
+ resolve(uri); // Fallback to raw image
306
+ }
307
+ }
308
+ })();
216
309
  });
217
310
  }