@smileid/web-components 11.5.0 → 11.6.0

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 (82) hide show
  1. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js +5398 -0
  2. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
  3. package/dist/esm/{Navigation-Xg565kcu.js → Navigation-6DH3vF4-.js} +2 -2
  4. package/dist/esm/{Navigation-Xg565kcu.js.map → Navigation-6DH3vF4-.js.map} +1 -1
  5. package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
  6. package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
  7. package/dist/esm/{SelfieCaptureScreens-D3KuMzZA.js → SelfieCaptureScreens-CtX-4Tco.js} +5 -6
  8. package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
  9. package/dist/esm/combobox.js +1 -1
  10. package/dist/esm/document.js +1 -1
  11. package/dist/esm/end-user-consent.js +713 -2
  12. package/dist/esm/end-user-consent.js.map +1 -1
  13. package/dist/esm/index-BqyuTk9f.js +1366 -0
  14. package/dist/esm/{index-CUwa6MPI.js.map → index-BqyuTk9f.js.map} +1 -1
  15. package/dist/esm/localisation.js +1 -1
  16. package/dist/esm/main.js +14 -14
  17. package/dist/esm/navigation.js +1 -1
  18. package/dist/esm/{package-BmVbDNny.js → package-CjZI-cNQ.js} +177 -172
  19. package/dist/esm/{package-BmVbDNny.js.map → package-CjZI-cNQ.js.map} +1 -1
  20. package/dist/esm/selfie.js +1 -1
  21. package/dist/esm/smart-camera-web.js +32 -18
  22. package/dist/esm/smart-camera-web.js.map +1 -1
  23. package/dist/esm/totp-consent.js +731 -2
  24. package/dist/esm/totp-consent.js.map +1 -1
  25. package/dist/esm/validate.js +31 -0
  26. package/dist/esm/validate.js.map +1 -0
  27. package/dist/smart-camera-web.js +696 -321
  28. package/dist/smart-camera-web.js.map +1 -1
  29. package/dist/types/main.d.ts +7 -1
  30. package/dist/types/validate.d.ts +21 -0
  31. package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
  32. package/lib/components/document/src/assets/lottie.d.ts +12 -0
  33. package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
  34. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
  35. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
  36. package/lib/components/document/src/document-auto-capture/README.md +73 -0
  37. package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
  38. package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
  39. package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
  40. package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
  41. package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
  42. package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
  43. package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
  44. package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
  45. package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
  46. package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
  47. package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
  48. package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
  49. package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
  50. package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
  51. package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
  52. package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
  53. package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
  54. package/lib/components/document/src/document-auto-capture/index.ts +4 -0
  55. package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
  56. package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
  57. package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
  58. package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
  59. package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
  60. package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
  61. package/lib/components/document/src/document-capture-submission/index.js +3 -0
  62. package/lib/components/selfie/README.md +13 -0
  63. package/lib/components/signature-pad/package.json +1 -1
  64. package/lib/components/smart-camera-web/src/README.md +11 -0
  65. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +25 -1
  66. package/lib/components/totp-consent/src/TotpConsent.js +1 -1
  67. package/package.json +8 -4
  68. package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js +0 -2232
  69. package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +0 -1
  70. package/dist/esm/EndUserConsent-CsiwoThZ.js +0 -717
  71. package/dist/esm/EndUserConsent-CsiwoThZ.js.map +0 -1
  72. package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
  73. package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +0 -1
  74. package/dist/esm/TotpConsent-CRtmtudl.js +0 -734
  75. package/dist/esm/TotpConsent-CRtmtudl.js.map +0 -1
  76. package/dist/esm/index-CUwa6MPI.js +0 -1363
  77. package/dist/esm/styles-BTEClL7R.js +0 -419
  78. package/dist/esm/styles-BTEClL7R.js.map +0 -1
  79. /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
  80. /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
  81. /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
  82. /package/lib/components/document/src/assets/lottie/{taking photo of passport 2.lottie → passport.lottie} +0 -0
@@ -0,0 +1,58 @@
1
+ export const FULLSCREEN_CAPTURE_LAYOUT = {
2
+ reservedVerticalPx: 90,
3
+ maxGuideWidthPx: 600,
4
+ widthRatio: 1.0,
5
+ minHeightRatio: 0.55,
6
+ minGuideWidthPx: 220,
7
+ defaultHorizontalInsetPx: 4,
8
+ sideControlsInsetPx: 132,
9
+ };
10
+
11
+ export interface FullscreenGuideSizeParams {
12
+ aspectRatio?: number;
13
+ displayHeight?: number;
14
+ displayWidth?: number;
15
+ horizontalInsetPx?: number;
16
+ reservedVerticalPx?: number;
17
+ }
18
+
19
+ export function getFullscreenGuideSize({
20
+ aspectRatio,
21
+ displayHeight,
22
+ displayWidth,
23
+ horizontalInsetPx = FULLSCREEN_CAPTURE_LAYOUT.defaultHorizontalInsetPx,
24
+ reservedVerticalPx = FULLSCREEN_CAPTURE_LAYOUT.reservedVerticalPx,
25
+ }: FullscreenGuideSizeParams) {
26
+ const width = Math.max(1, displayWidth || 0);
27
+ const height = Math.max(1, displayHeight || 0);
28
+ const ratio = Math.max(0.2, aspectRatio || 1.585);
29
+
30
+ const availableGuideHeight = Math.max(
31
+ height - reservedVerticalPx,
32
+ height * FULLSCREEN_CAPTURE_LAYOUT.minHeightRatio,
33
+ );
34
+
35
+ const maxWidthFromHeight = availableGuideHeight * ratio;
36
+ const maxWidthFromInsets = Math.max(
37
+ FULLSCREEN_CAPTURE_LAYOUT.minGuideWidthPx,
38
+ width - horizontalInsetPx * 2,
39
+ );
40
+
41
+ const guideWidth = Math.max(
42
+ FULLSCREEN_CAPTURE_LAYOUT.minGuideWidthPx,
43
+ Math.min(
44
+ width * FULLSCREEN_CAPTURE_LAYOUT.widthRatio,
45
+ FULLSCREEN_CAPTURE_LAYOUT.maxGuideWidthPx,
46
+ maxWidthFromHeight,
47
+ maxWidthFromInsets,
48
+ ),
49
+ );
50
+
51
+ const guideHeight = guideWidth / ratio;
52
+
53
+ return {
54
+ guideWidth,
55
+ guideHeight,
56
+ reservedVerticalPx,
57
+ };
58
+ }
@@ -0,0 +1,40 @@
1
+ export const CHROMA_DISABLE_ERROR_THRESHOLD = 3;
2
+ export const CV_ERROR_FALLBACK_THRESHOLD = 6;
3
+
4
+ type RecoveryInput = {
5
+ errorStreak: number;
6
+ chromaUnavailable: boolean;
7
+ chromaDisableThreshold?: number;
8
+ fallbackThreshold?: number;
9
+ };
10
+
11
+ type RecoveryAction = {
12
+ nextErrorStreak: number;
13
+ shouldDisableChroma: boolean;
14
+ shouldClearProcessingError: boolean;
15
+ shouldActivateFallback: boolean;
16
+ shouldSuspendDetection: boolean;
17
+ };
18
+
19
+ export function nextCvErrorRecoveryAction({
20
+ errorStreak,
21
+ chromaUnavailable,
22
+ chromaDisableThreshold = CHROMA_DISABLE_ERROR_THRESHOLD,
23
+ fallbackThreshold = CV_ERROR_FALLBACK_THRESHOLD,
24
+ }: RecoveryInput): RecoveryAction {
25
+ const nextErrorStreak = errorStreak + 1;
26
+ const shouldDisableChroma =
27
+ !chromaUnavailable && nextErrorStreak >= chromaDisableThreshold;
28
+ const chromaUnavailableAfterError = chromaUnavailable || shouldDisableChroma;
29
+
30
+ const shouldActivateFallback =
31
+ chromaUnavailableAfterError && nextErrorStreak >= fallbackThreshold;
32
+
33
+ return {
34
+ nextErrorStreak,
35
+ shouldDisableChroma,
36
+ shouldClearProcessingError: shouldDisableChroma,
37
+ shouldActivateFallback,
38
+ shouldSuspendDetection: shouldActivateFallback,
39
+ };
40
+ }
@@ -0,0 +1,20 @@
1
+ export const ASPECT_RATIOS = {
2
+ 'id-card': 1.585,
3
+ passport: 1.42,
4
+ greenbook: 1.42,
5
+ };
6
+
7
+ export type AspectKey = keyof typeof ASPECT_RATIOS;
8
+ export type DiscoveryVote = 'id-card' | 'passport';
9
+
10
+ export const isAspectKey = (v: unknown): v is AspectKey =>
11
+ typeof v === 'string' &&
12
+ Object.prototype.hasOwnProperty.call(ASPECT_RATIOS, v);
13
+
14
+ const ASPECT_RATIO_MIDPOINT =
15
+ (ASPECT_RATIOS['id-card'] + ASPECT_RATIOS.passport) / 2;
16
+
17
+ export const classifyDiscoveryAspect = (
18
+ normalizedAspect: number,
19
+ ): DiscoveryVote =>
20
+ normalizedAspect >= ASPECT_RATIO_MIDPOINT ? 'id-card' : 'passport';
@@ -0,0 +1,35 @@
1
+ export const SYNTHETIC_CONTOUR_CONFIDENCE = 0.55;
2
+
3
+ export const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
4
+
5
+ const QUALITY_WEIGHTS = {
6
+ sharpness: 0.35,
7
+ glare: 0.15,
8
+ fill: 0.2,
9
+ aspect: 0.2,
10
+ contour: 0.1,
11
+ chroma: 0.05,
12
+ };
13
+
14
+ export interface FrameQualityParts {
15
+ sharpness?: number | null;
16
+ glare?: number | null;
17
+ fill?: number | null;
18
+ aspect?: number | null;
19
+ contour?: number | null;
20
+ chroma?: number | null;
21
+ }
22
+
23
+ export function frameQualityScore(parts: FrameQualityParts): number {
24
+ const keys = Object.keys(QUALITY_WEIGHTS) as Array<
25
+ keyof typeof QUALITY_WEIGHTS
26
+ >;
27
+ const present = keys.filter((key) => parts[key] != null);
28
+ const den = present.reduce((sum, key) => sum + QUALITY_WEIGHTS[key], 0);
29
+ if (den <= 0) return 0;
30
+ const num = present.reduce(
31
+ (sum, key) => sum + clamp01(parts[key] as number) * QUALITY_WEIGHTS[key],
32
+ 0,
33
+ );
34
+ return num / den;
35
+ }
@@ -0,0 +1,209 @@
1
+ // Seam / straight-line rejection for the document contour pass.
2
+ //
3
+ // On surfaces with strong straight linear features — a parquet/plank floor, a
4
+ // slatted table — the seams between planks produce long, high-contrast straight
5
+ // edges that survive the adaptive-Canny high threshold. A card-shaped quad can
6
+ // then be framed by those background lines instead of a real document border,
7
+ // especially for a low-contrast (e.g. dark) card whose own border gradient is
8
+ // weak. The existing geometry gates (aspect / fill / angles / wall-hug) are
9
+ // proxies that such a seam-quad can still pass.
10
+ //
11
+ // Discriminator: a real card edge is a BOUNDED segment ending at two corners
12
+ // where perpendicular edges meet. A seam is a THROUGH-LINE that continues PAST
13
+ // the candidate's corners. For each of the quad's 4 edges we look for a Hough
14
+ // line segment that is collinear with the edge AND overshoots both of its
15
+ // endpoints (or runs to the ROI boundary). If >= minSeamEdges edges sit on such
16
+ // through-lines, the quad is framed by background lines, not a card → reject.
17
+ //
18
+ // Pure module (no OpenCV): the hook runs cv.HoughLinesP and passes plain arrays
19
+ // in, so the geometry is unit-testable in isolation (mirrors qualityScoring.ts).
20
+
21
+ export interface Segment {
22
+ x1: number;
23
+ y1: number;
24
+ x2: number;
25
+ y2: number;
26
+ }
27
+
28
+ export interface Corner {
29
+ x: number;
30
+ y: number;
31
+ }
32
+
33
+ export interface SeamParams {
34
+ // Max angular difference (deg) for a segment to count as parallel to an edge.
35
+ angleTolDeg?: number;
36
+ // Max perpendicular distance (px) from the edge midpoint to the segment's
37
+ // infinite line for the two to be considered collinear (same line).
38
+ distTolPx?: number;
39
+ // A collinear segment must overshoot an edge endpoint by more than this
40
+ // fraction of the edge length to count as "extending past" that corner.
41
+ overshootFrac?: number;
42
+ // Number of edges that must sit on through-lines to reject the quad.
43
+ minSeamEdges?: number;
44
+ // Optional ROI size: when provided, a segment endpoint within boundMarginPx
45
+ // of a ROI wall also satisfies overshoot on that side (a seam running to the
46
+ // ROI edge when the card sits near the frame border).
47
+ roiW?: number;
48
+ roiH?: number;
49
+ boundMarginPx?: number;
50
+ }
51
+
52
+ // Documented defaults. Only the Hough acquisition knobs (threshold, min length,
53
+ // max gap, enable) are exposed to the tuning panel; these geometric tolerances
54
+ // are intentionally fixed constants.
55
+ const DEFAULTS = {
56
+ angleTolDeg: 8,
57
+ distTolPx: 6,
58
+ overshootFrac: 0.15,
59
+ minSeamEdges: 2,
60
+ boundMarginPx: 4,
61
+ };
62
+
63
+ // Orientation of a vector in [0, 180) degrees (lines are undirected).
64
+ function lineAngleDeg(dx: number, dy: number): number {
65
+ let a = (Math.atan2(dy, dx) * 180) / Math.PI;
66
+ if (a < 0) a += 180;
67
+ if (a >= 180) a -= 180;
68
+ return a;
69
+ }
70
+
71
+ // Smallest absolute difference between two [0,180) line angles, in [0, 90].
72
+ function angleDiffDeg(a: number, b: number): number {
73
+ let d = Math.abs(a - b) % 180;
74
+ if (d > 90) d = 180 - d;
75
+ return d;
76
+ }
77
+
78
+ interface ResolvedParams {
79
+ angleTol: number;
80
+ distTol: number;
81
+ overshoot: number;
82
+ nearRoiWall: (x: number, y: number) => boolean;
83
+ }
84
+
85
+ // True when `seg` is collinear with edge A→B and overshoots both endpoints
86
+ // (or runs to a ROI wall). Uses early returns rather than `continue` so the
87
+ // caller can express the per-edge test as a single `Array.some`.
88
+ function segmentIsThroughLine(
89
+ a: Corner,
90
+ b: Corner,
91
+ ux: number,
92
+ uy: number,
93
+ len: number,
94
+ edgeAngle: number,
95
+ mx: number,
96
+ my: number,
97
+ seg: Segment,
98
+ p: ResolvedParams,
99
+ ): boolean {
100
+ const sx = seg.x2 - seg.x1;
101
+ const sy = seg.y2 - seg.y1;
102
+ const segLen = Math.sqrt(sx * sx + sy * sy);
103
+ if (segLen === 0) return false;
104
+
105
+ // (a) parallel?
106
+ if (angleDiffDeg(edgeAngle, lineAngleDeg(sx, sy)) > p.angleTol) return false;
107
+
108
+ // (b) collinear? perpendicular distance of edge midpoint to the segment's
109
+ // infinite line = |(M - P) x segDir| / |segDir|.
110
+ const wx = mx - seg.x1;
111
+ const wy = my - seg.y1;
112
+ const perpDist = Math.abs(wx * sy - wy * sx) / segLen;
113
+ if (perpDist > p.distTol) return false;
114
+
115
+ // (c) overshoot: project the segment's endpoints onto the edge axis (origin
116
+ // at A). The edge spans t in [0, len].
117
+ const tP = (seg.x1 - a.x) * ux + (seg.y1 - a.y) * uy;
118
+ const tQ = (seg.x2 - a.x) * ux + (seg.y2 - a.y) * uy;
119
+ const s0 = Math.min(tP, tQ);
120
+ const s1 = Math.max(tP, tQ);
121
+ const overshootBeforeA =
122
+ s0 < -p.overshoot * len ||
123
+ p.nearRoiWall(s0 === tP ? seg.x1 : seg.x2, s0 === tP ? seg.y1 : seg.y2);
124
+ const overshootAfterB =
125
+ s1 > len + p.overshoot * len ||
126
+ p.nearRoiWall(s1 === tP ? seg.x1 : seg.x2, s1 === tP ? seg.y1 : seg.y2);
127
+ return overshootBeforeA && overshootAfterB;
128
+ }
129
+
130
+ // Whether edge A→B sits on a background through-line.
131
+ function edgeOnThroughLine(
132
+ a: Corner,
133
+ b: Corner,
134
+ segments: Segment[],
135
+ p: ResolvedParams,
136
+ ): boolean {
137
+ const ex = b.x - a.x;
138
+ const ey = b.y - a.y;
139
+ const len = Math.sqrt(ex * ex + ey * ey);
140
+ if (len === 0) return false;
141
+ const ux = ex / len; // unit direction along the edge
142
+ const uy = ey / len;
143
+ const edgeAngle = lineAngleDeg(ex, ey);
144
+ const mx = (a.x + b.x) / 2; // edge midpoint
145
+ const my = (a.y + b.y) / 2;
146
+ return segments.some((seg) =>
147
+ segmentIsThroughLine(a, b, ux, uy, len, edgeAngle, mx, my, seg, p),
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Classify each of the quad's 4 edges as lying on a background "through-line".
153
+ * Returns the per-edge flags and the count of through-line edges.
154
+ */
155
+ export function classifyEdgesOnThroughLines(
156
+ corners: Corner[],
157
+ segments: Segment[],
158
+ params: SeamParams = {},
159
+ ): { seamEdgeCount: number; perEdge: boolean[] } {
160
+ const boundMargin = params.boundMarginPx ?? DEFAULTS.boundMarginPx;
161
+ const { roiW, roiH } = params;
162
+ const resolved: ResolvedParams = {
163
+ angleTol: params.angleTolDeg ?? DEFAULTS.angleTolDeg,
164
+ distTol: params.distTolPx ?? DEFAULTS.distTolPx,
165
+ overshoot: params.overshootFrac ?? DEFAULTS.overshootFrac,
166
+ nearRoiWall: (x: number, y: number): boolean =>
167
+ roiW != null &&
168
+ roiH != null &&
169
+ (x <= boundMargin ||
170
+ y <= boundMargin ||
171
+ x >= roiW - boundMargin ||
172
+ y >= roiH - boundMargin),
173
+ };
174
+
175
+ const perEdge = [false, false, false, false];
176
+ if (!corners || corners.length !== 4 || !segments || segments.length === 0) {
177
+ return { seamEdgeCount: 0, perEdge };
178
+ }
179
+
180
+ for (let i = 0; i < 4; i++) {
181
+ perEdge[i] = edgeOnThroughLine(
182
+ corners[i],
183
+ corners[(i + 1) % 4],
184
+ segments,
185
+ resolved,
186
+ );
187
+ }
188
+
189
+ const seamEdgeCount = perEdge.reduce((n, flag) => n + (flag ? 1 : 0), 0);
190
+ return { seamEdgeCount, perEdge };
191
+ }
192
+
193
+ /**
194
+ * True when the quad is framed by background straight lines (a seam artifact)
195
+ * rather than a real card border.
196
+ */
197
+ export function isSeamFalseQuad(
198
+ corners: Corner[],
199
+ segments: Segment[],
200
+ params: SeamParams = {},
201
+ ): boolean {
202
+ const minSeamEdges = params.minSeamEdges ?? DEFAULTS.minSeamEdges;
203
+ const { seamEdgeCount } = classifyEdgesOnThroughLines(
204
+ corners,
205
+ segments,
206
+ params,
207
+ );
208
+ return seamEdgeCount >= minSeamEdges;
209
+ }
@@ -0,0 +1,10 @@
1
+ export const SYNTH_BRIDGE_WINDOW_MS = 500;
2
+
3
+ export function isSyntheticBridgeRecent(
4
+ lastRealCardAtMs: number | null,
5
+ nowMs: number,
6
+ ): boolean {
7
+ if (lastRealCardAtMs == null) return false;
8
+ const elapsedMs = nowMs - lastRealCardAtMs;
9
+ return elapsedMs >= 0 && elapsedMs <= SYNTH_BRIDGE_WINDOW_MS;
10
+ }
@@ -0,0 +1,117 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+
3
+ /**
4
+ * Acquire and manage the rear-facing camera stream for document capture.
5
+ * Mirrors the id-scanner implementation: tries progressively relaxed
6
+ * constraints (1920×1080 → environment-only → any video) so older devices
7
+ * still get a usable stream.
8
+ */
9
+ export function useCamera() {
10
+ const videoRef = useRef<HTMLVideoElement | null>(null);
11
+ const [error, setError] = useState<string | null>(null);
12
+ const [stream, setStream] = useState<MediaStream | null>(null);
13
+
14
+ useEffect(() => {
15
+ let cancelled = false;
16
+ let currentStream: MediaStream | null = null;
17
+
18
+ const startCamera = async () => {
19
+ try {
20
+ const constraintsList: MediaStreamConstraints[] = [
21
+ {
22
+ audio: false,
23
+ video: {
24
+ facingMode: 'environment',
25
+ width: { ideal: 1920 },
26
+ height: { ideal: 1080 },
27
+ },
28
+ },
29
+ {
30
+ audio: false,
31
+ video: { facingMode: 'environment' },
32
+ },
33
+ {
34
+ audio: false,
35
+ video: true,
36
+ },
37
+ ];
38
+
39
+ // Sequential fallback: try each constraint set; only attempt the next
40
+ // if the previous one rejects. Implemented as a small recursive helper
41
+ // so we avoid both `await`-in-loop and the contrived reduce/Promise
42
+ // chain that the previous version used.
43
+ const tryConstraints = (
44
+ index: number,
45
+ lastError?: Error,
46
+ ): Promise<MediaStream> => {
47
+ if (index >= constraintsList.length) {
48
+ return Promise.reject(
49
+ lastError || new Error('All camera constraints failed'),
50
+ );
51
+ }
52
+ return navigator.mediaDevices
53
+ .getUserMedia(constraintsList[index])
54
+ .catch((e: Error) => {
55
+ console.warn(
56
+ 'Camera constraint failed, trying next:',
57
+ e?.message,
58
+ );
59
+ return tryConstraints(index + 1, e);
60
+ });
61
+ };
62
+
63
+ const mediaStream = await tryConstraints(0);
64
+
65
+ // The component may have unmounted while getUserMedia was pending; if
66
+ // so, stop the freshly-acquired stream immediately so we don't leak the
67
+ // camera (the cleanup below ran before currentStream was assigned).
68
+ if (cancelled) {
69
+ mediaStream.getTracks().forEach((track) => track.stop());
70
+ return;
71
+ }
72
+
73
+ const track = mediaStream.getVideoTracks()[0];
74
+
75
+ // Best-effort continuous autofocus / exposure / white balance.
76
+ // Laptop webcams in particular benefit from this — many ship with
77
+ // continuous AF available but not enabled by default. Each constraint
78
+ // is applied independently so an unsupported one doesn't kill the
79
+ // others.
80
+ const tryApply = async (constraint: MediaTrackConstraints) => {
81
+ try {
82
+ await track.applyConstraints(constraint);
83
+ } catch {
84
+ /* unsupported, ignore */
85
+ }
86
+ };
87
+ await tryApply({
88
+ advanced: [{ focusMode: 'continuous' } as MediaTrackConstraintSet],
89
+ });
90
+ // await tryApply({ advanced: [{ exposureMode: 'continuous' } as MediaTrackConstraintSet] });
91
+ // await tryApply({ advanced: [{ whiteBalanceMode: 'continuous' } as MediaTrackConstraintSet] });
92
+
93
+ if (videoRef.current) {
94
+ videoRef.current.srcObject = mediaStream;
95
+ await videoRef.current.play();
96
+ }
97
+
98
+ setStream(mediaStream);
99
+ currentStream = mediaStream;
100
+ } catch (err) {
101
+ console.error('Camera access failed:', err);
102
+ setError('Camera access denied or unavailable.');
103
+ }
104
+ };
105
+
106
+ startCamera();
107
+
108
+ return () => {
109
+ cancelled = true;
110
+ if (currentStream) {
111
+ currentStream.getTracks().forEach((track) => track.stop());
112
+ }
113
+ };
114
+ }, []);
115
+
116
+ return { videoRef, error, stream };
117
+ }