@simprints/simface-sdk 0.5.1 → 0.7.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.
- package/README.md +52 -4
- package/dist/components/simface-capture.d.ts +15 -19
- package/dist/components/simface-capture.js +176 -250
- package/dist/components/simface-capture.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/services/camera.d.ts +8 -12
- package/dist/services/camera.js +240 -339
- package/dist/services/camera.js.map +1 -1
- package/dist/services/face-quality.js +6 -6
- package/dist/services/face-quality.js.map +1 -1
- package/dist/shared/auto-capture.d.ts +7 -0
- package/dist/shared/auto-capture.js +23 -0
- package/dist/shared/auto-capture.js.map +1 -0
- package/dist/shared/capture-flow.d.ts +28 -0
- package/dist/shared/capture-flow.js +61 -0
- package/dist/shared/capture-flow.js.map +1 -0
- package/dist/shared/capture-runtime.d.ts +21 -0
- package/dist/shared/capture-runtime.js +183 -0
- package/dist/shared/capture-runtime.js.map +1 -0
- package/dist/shared/capture-session.d.ts +99 -0
- package/dist/shared/capture-session.js +287 -0
- package/dist/shared/capture-session.js.map +1 -0
- package/dist/simface-sdk.js +2767 -2464
- package/dist/simface-sdk.umd.cjs +37 -37
- package/dist/types/index.d.ts +17 -0
- package/package.json +1 -1
package/dist/services/camera.js
CHANGED
|
@@ -1,85 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Camera capture service.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Plans capture explicitly as an ordered fallback chain:
|
|
5
|
+
* auto camera -> manual camera -> media picker.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { CAPTURE_GUIDE_MASK_PATH, CAPTURE_GUIDE_PATH, } from '../shared/auto-capture.js';
|
|
8
|
+
import { buildCapturePlan, normalizeCaptureOptions, resolveCaptureCapabilities, } from '../shared/capture-flow.js';
|
|
9
|
+
import { CameraCaptureSessionController, } from '../shared/capture-session.js';
|
|
10
|
+
import { CameraAccessError, blobToDataURL, blobToImage, captureFromFileInput, openUserFacingCameraStream, } from '../shared/capture-runtime.js';
|
|
8
11
|
const CAPTURE_DIALOG_Z_INDEX = '2147483647';
|
|
9
|
-
const AUTO_CAPTURE_ANALYSIS_INTERVAL_MS = 180;
|
|
10
|
-
const AUTO_CAPTURE_STABLE_FRAMES = 3;
|
|
11
12
|
/**
|
|
12
|
-
* Opens the
|
|
13
|
+
* Opens the configured capture presentation and returns a confirmed image Blob,
|
|
14
|
+
* or null if the user cancels.
|
|
13
15
|
*/
|
|
14
|
-
export async function captureFromCamera() {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
export async function captureFromCamera(options) {
|
|
17
|
+
const captureOptions = normalizeCaptureOptions(options);
|
|
18
|
+
if (captureOptions.presentation === 'embedded') {
|
|
19
|
+
return captureFromEmbeddedComponent(captureOptions);
|
|
17
20
|
}
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const capabilities = await resolveCaptureCapabilities({
|
|
22
|
+
capturePreference: captureOptions.capturePreference,
|
|
23
|
+
});
|
|
24
|
+
const plan = buildCapturePlan(captureOptions, capabilities);
|
|
25
|
+
for (let index = 0; index < plan.steps.length; index += 1) {
|
|
26
|
+
const step = plan.steps[index];
|
|
27
|
+
if (step === 'media-picker') {
|
|
28
|
+
return captureFromFileInput();
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await captureFromPopupCamera(step === 'auto-camera' ? 'auto' : 'manual');
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const hasMediaPickerFallback = plan.steps.slice(index + 1).includes('media-picker');
|
|
35
|
+
if (error instanceof CameraAccessError && hasMediaPickerFallback) {
|
|
36
|
+
return captureFromFileInput();
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
20
40
|
}
|
|
21
|
-
|
|
22
|
-
return captureFromMediaDevices(mode);
|
|
41
|
+
throw new Error('No supported capture strategy is available in this environment.');
|
|
23
42
|
}
|
|
24
|
-
async function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
async function captureFromEmbeddedComponent(options) {
|
|
44
|
+
await import('../components/simface-capture.js');
|
|
45
|
+
const host = resolveEmbeddedCaptureHost(options.container);
|
|
46
|
+
const usingExistingElement = host.tagName.toLowerCase() === 'simface-capture';
|
|
47
|
+
const element = (usingExistingElement
|
|
48
|
+
? host
|
|
49
|
+
: document.createElement('simface-capture'));
|
|
50
|
+
if (!usingExistingElement) {
|
|
51
|
+
host.appendChild(element);
|
|
28
52
|
}
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
element.embedded = true;
|
|
54
|
+
element.label = options.label;
|
|
55
|
+
element.confirmLabel = options.confirmLabel;
|
|
56
|
+
element.capturePreference = options.capturePreference;
|
|
57
|
+
element.allowMediaPickerFallback = options.allowMediaPickerFallback;
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const cleanup = () => {
|
|
60
|
+
element.removeEventListener('simface-captured', handleCaptured);
|
|
61
|
+
element.removeEventListener('simface-cancelled', handleCancelled);
|
|
62
|
+
element.removeEventListener('simface-error', handleError);
|
|
63
|
+
element.active = false;
|
|
64
|
+
if (!usingExistingElement) {
|
|
65
|
+
element.remove();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const handleCaptured = (event) => {
|
|
69
|
+
cleanup();
|
|
70
|
+
resolve(event.detail.imageBlob);
|
|
71
|
+
};
|
|
72
|
+
const handleCancelled = () => {
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve(null);
|
|
75
|
+
};
|
|
76
|
+
const handleError = (event) => {
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error(event.detail.error));
|
|
79
|
+
};
|
|
80
|
+
element.addEventListener('simface-captured', handleCaptured);
|
|
81
|
+
element.addEventListener('simface-cancelled', handleCancelled);
|
|
82
|
+
element.addEventListener('simface-error', handleError);
|
|
83
|
+
void element.startCapture().catch((error) => {
|
|
84
|
+
cleanup();
|
|
85
|
+
reject(error instanceof Error ? error : new Error('Embedded capture failed.'));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function resolveEmbeddedCaptureHost(container) {
|
|
90
|
+
if (!container) {
|
|
91
|
+
throw new Error('Embedded capture requires a container element or selector.');
|
|
31
92
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return true;
|
|
93
|
+
if (container instanceof HTMLElement) {
|
|
94
|
+
return container;
|
|
35
95
|
}
|
|
36
|
-
|
|
37
|
-
|
|
96
|
+
const element = document.querySelector(container);
|
|
97
|
+
if (!element) {
|
|
98
|
+
throw new Error(`No element matched the embedded capture container selector "${container}".`);
|
|
38
99
|
}
|
|
100
|
+
return element;
|
|
39
101
|
}
|
|
40
|
-
async function
|
|
41
|
-
let stream;
|
|
42
|
-
try {
|
|
43
|
-
stream = await navigator.mediaDevices.getUserMedia({
|
|
44
|
-
video: { facingMode: { ideal: 'user' } },
|
|
45
|
-
audio: false,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
throw new Error(describeCameraError(error));
|
|
50
|
-
}
|
|
51
|
-
let streamStopped = false;
|
|
52
|
-
const stopStream = () => {
|
|
53
|
-
if (streamStopped) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
streamStopped = true;
|
|
57
|
-
stream.getTracks().forEach((track) => track.stop());
|
|
58
|
-
};
|
|
102
|
+
async function captureFromPopupCamera(initialMode) {
|
|
59
103
|
return new Promise((resolve, reject) => {
|
|
60
104
|
let settled = false;
|
|
61
|
-
let mode = initialMode;
|
|
62
105
|
let overlay = null;
|
|
63
106
|
let previewUrl = '';
|
|
64
|
-
let
|
|
65
|
-
let
|
|
107
|
+
let stream = null;
|
|
108
|
+
let controller = null;
|
|
66
109
|
let escapeHandler = null;
|
|
67
|
-
let lastAnalysisTimestamp = 0;
|
|
68
|
-
let stableFrameCount = 0;
|
|
69
|
-
let analysisInFlight = false;
|
|
70
|
-
let previewActive = false;
|
|
71
|
-
let videoReady = false;
|
|
72
110
|
const cleanup = () => {
|
|
73
|
-
|
|
74
|
-
window.cancelAnimationFrame(animationFrameId);
|
|
75
|
-
}
|
|
111
|
+
controller?.stop();
|
|
76
112
|
if (escapeHandler) {
|
|
77
113
|
window.removeEventListener('keydown', escapeHandler);
|
|
78
114
|
}
|
|
79
115
|
if (previewUrl) {
|
|
80
116
|
URL.revokeObjectURL(previewUrl);
|
|
81
117
|
}
|
|
82
|
-
|
|
118
|
+
if (stream) {
|
|
119
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
120
|
+
}
|
|
83
121
|
overlay?.remove();
|
|
84
122
|
};
|
|
85
123
|
const finalize = (value, error) => {
|
|
@@ -94,68 +132,6 @@ async function captureFromMediaDevices(initialMode) {
|
|
|
94
132
|
}
|
|
95
133
|
resolve(value);
|
|
96
134
|
};
|
|
97
|
-
const renderCaptureMode = (video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton) => {
|
|
98
|
-
previewActive = false;
|
|
99
|
-
stableFrameCount = 0;
|
|
100
|
-
if (previewUrl) {
|
|
101
|
-
URL.revokeObjectURL(previewUrl);
|
|
102
|
-
previewUrl = '';
|
|
103
|
-
}
|
|
104
|
-
previewBlob = null;
|
|
105
|
-
mediaContainer.replaceChildren(video, createGuideOverlay());
|
|
106
|
-
if (videoReady) {
|
|
107
|
-
resumeVideoPreview(video);
|
|
108
|
-
}
|
|
109
|
-
title.textContent = mode === 'auto' ? 'Center your face' : 'Take a face photo';
|
|
110
|
-
copy.textContent =
|
|
111
|
-
mode === 'auto'
|
|
112
|
-
? 'Keep your face inside the oval. We will capture automatically when the framing looks good.'
|
|
113
|
-
: 'Line up your face in the oval, then take a photo manually.';
|
|
114
|
-
feedback.textContent =
|
|
115
|
-
mode === 'auto'
|
|
116
|
-
? 'Looking for a single face in frame...'
|
|
117
|
-
: 'When you are ready, press Take photo.';
|
|
118
|
-
setFeedbackState(feedback, mode === 'auto' ? 'neutral' : 'manual');
|
|
119
|
-
captureButton.style.display = mode === 'manual' ? 'inline-flex' : 'none';
|
|
120
|
-
captureButton.disabled = true;
|
|
121
|
-
actions.replaceChildren(cancelButton, captureButton);
|
|
122
|
-
};
|
|
123
|
-
const renderPreviewMode = (video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, qualityResult) => {
|
|
124
|
-
previewActive = true;
|
|
125
|
-
stableFrameCount = 0;
|
|
126
|
-
if (previewUrl) {
|
|
127
|
-
URL.revokeObjectURL(previewUrl);
|
|
128
|
-
}
|
|
129
|
-
previewUrl = URL.createObjectURL(blob);
|
|
130
|
-
previewBlob = blob;
|
|
131
|
-
const image = document.createElement('img');
|
|
132
|
-
image.alt = 'Captured face preview';
|
|
133
|
-
image.src = previewUrl;
|
|
134
|
-
applyStyles(image, {
|
|
135
|
-
position: 'absolute',
|
|
136
|
-
inset: '0',
|
|
137
|
-
zIndex: '1',
|
|
138
|
-
width: '100%',
|
|
139
|
-
height: '100%',
|
|
140
|
-
objectFit: 'cover',
|
|
141
|
-
});
|
|
142
|
-
mediaContainer.replaceChildren(video, image);
|
|
143
|
-
title.textContent = 'Review your photo';
|
|
144
|
-
copy.textContent =
|
|
145
|
-
qualityResult?.passesQualityChecks === false
|
|
146
|
-
? 'The capture did not pass the checks. Retake the photo.'
|
|
147
|
-
: 'Confirm this photo or retake it.';
|
|
148
|
-
feedback.textContent = qualityResult?.message ?? 'Review the captured image before continuing.';
|
|
149
|
-
setFeedbackState(feedback, qualityResult
|
|
150
|
-
? qualityResult.passesQualityChecks
|
|
151
|
-
? 'success'
|
|
152
|
-
: 'error'
|
|
153
|
-
: 'manual');
|
|
154
|
-
actions.replaceChildren(cancelButton, retakeButton);
|
|
155
|
-
if (qualityResult?.passesQualityChecks !== false) {
|
|
156
|
-
actions.append(confirmButton);
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
135
|
try {
|
|
160
136
|
overlay = document.createElement('div');
|
|
161
137
|
overlay.setAttribute('data-simface-camera-overlay', 'true');
|
|
@@ -210,11 +186,11 @@ async function captureFromMediaDevices(initialMode) {
|
|
|
210
186
|
video.autoplay = true;
|
|
211
187
|
video.muted = true;
|
|
212
188
|
video.playsInline = true;
|
|
213
|
-
video.srcObject = stream;
|
|
214
189
|
applyStyles(video, {
|
|
215
190
|
width: '100%',
|
|
216
191
|
height: '100%',
|
|
217
192
|
objectFit: 'cover',
|
|
193
|
+
transform: 'scaleX(-1)',
|
|
218
194
|
});
|
|
219
195
|
const feedback = document.createElement('div');
|
|
220
196
|
applyStyles(feedback, {
|
|
@@ -233,202 +209,144 @@ async function captureFromMediaDevices(initialMode) {
|
|
|
233
209
|
cancelButton.dataset.simfaceAction = 'cancel';
|
|
234
210
|
const captureButton = createActionButton('Take photo', 'primary');
|
|
235
211
|
captureButton.dataset.simfaceAction = 'capture';
|
|
236
|
-
captureButton.style.display = mode === 'manual' ? 'inline-flex' : 'none';
|
|
237
|
-
captureButton.disabled = true;
|
|
238
212
|
const confirmButton = createActionButton('Use photo', 'primary');
|
|
239
213
|
confirmButton.dataset.simfaceAction = 'confirm';
|
|
240
214
|
const retakeButton = createActionButton('Retake', 'secondary');
|
|
241
215
|
retakeButton.dataset.simfaceAction = 'retake';
|
|
216
|
+
const guideOverlay = createGuideOverlay();
|
|
242
217
|
panel.append(title, copy, mediaContainer, feedback, actions);
|
|
243
218
|
overlay.append(panel);
|
|
244
219
|
document.body.appendChild(overlay);
|
|
245
|
-
renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton);
|
|
246
220
|
escapeHandler = (event) => {
|
|
247
221
|
if (event.key === 'Escape') {
|
|
248
222
|
finalize(null);
|
|
249
223
|
}
|
|
250
224
|
};
|
|
225
|
+
window.addEventListener('keydown', escapeHandler);
|
|
251
226
|
cancelButton.addEventListener('click', () => finalize(null));
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
finalize(null, new Error('Failed to
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
finalize(previewBlob);
|
|
227
|
+
captureButton.addEventListener('click', () => {
|
|
228
|
+
void controller?.takePhotoNow().catch((error) => {
|
|
229
|
+
finalize(null, error instanceof Error ? error : new Error('Failed to capture an image.'));
|
|
230
|
+
});
|
|
258
231
|
});
|
|
259
232
|
retakeButton.addEventListener('click', () => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
if (mode === 'auto') {
|
|
265
|
-
scheduleAutoCapture();
|
|
266
|
-
}
|
|
233
|
+
void controller?.retake().catch((error) => {
|
|
234
|
+
finalize(null, error instanceof Error ? error : new Error('Failed to restart the capture.'));
|
|
235
|
+
});
|
|
267
236
|
});
|
|
268
|
-
|
|
269
|
-
if (previewActive) {
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
237
|
+
confirmButton.addEventListener('click', () => {
|
|
272
238
|
try {
|
|
273
|
-
const blob =
|
|
274
|
-
|
|
275
|
-
renderPreviewMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, qualityResult);
|
|
239
|
+
const blob = controller?.confirm();
|
|
240
|
+
finalize(blob ?? null);
|
|
276
241
|
}
|
|
277
242
|
catch (error) {
|
|
278
|
-
finalize(null, error instanceof Error ? error : new Error('Failed to
|
|
243
|
+
finalize(null, error instanceof Error ? error : new Error('Failed to confirm the photo.'));
|
|
279
244
|
}
|
|
280
245
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
246
|
+
controller = new CameraCaptureSessionController({
|
|
247
|
+
videoElement: video,
|
|
248
|
+
initialMode,
|
|
249
|
+
copy: {
|
|
250
|
+
autoReadyMessage: 'Looking for a single face in frame...',
|
|
251
|
+
manualReadyMessage: 'When you are ready, press Take photo.',
|
|
252
|
+
autoUnavailableMessage: 'Automatic capture is unavailable in this browser. Use Take photo instead.',
|
|
253
|
+
retakeReadyMessage: 'When you are ready, press Take photo.',
|
|
254
|
+
},
|
|
255
|
+
onStateChange: (state) => {
|
|
256
|
+
renderPopupState({
|
|
257
|
+
state,
|
|
258
|
+
mediaContainer,
|
|
259
|
+
video,
|
|
260
|
+
title,
|
|
261
|
+
copy,
|
|
262
|
+
feedback,
|
|
263
|
+
actions,
|
|
264
|
+
cancelButton,
|
|
265
|
+
captureButton,
|
|
266
|
+
confirmButton,
|
|
267
|
+
retakeButton,
|
|
268
|
+
guideOverlay,
|
|
269
|
+
setPreviewUrl(nextUrl) {
|
|
270
|
+
if (previewUrl) {
|
|
271
|
+
URL.revokeObjectURL(previewUrl);
|
|
272
|
+
}
|
|
273
|
+
previewUrl = nextUrl;
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
},
|
|
293
277
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (previewActive ||
|
|
300
|
-
analysisInFlight ||
|
|
301
|
-
timestamp - lastAnalysisTimestamp < AUTO_CAPTURE_ANALYSIS_INTERVAL_MS) {
|
|
302
|
-
scheduleAutoCapture();
|
|
278
|
+
void (async () => {
|
|
279
|
+
try {
|
|
280
|
+
stream = await openUserFacingCameraStream();
|
|
281
|
+
if (settled) {
|
|
282
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
303
283
|
return;
|
|
304
284
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
stableFrameCount += 1;
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
stableFrameCount = 0;
|
|
316
|
-
}
|
|
317
|
-
if (stableFrameCount >= AUTO_CAPTURE_STABLE_FRAMES) {
|
|
318
|
-
const blob = await captureVideoFrame(video);
|
|
319
|
-
renderPreviewMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, qualityResult);
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
catch (error) {
|
|
324
|
-
mode = 'manual';
|
|
325
|
-
renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton);
|
|
326
|
-
captureButton.disabled = false;
|
|
327
|
-
feedback.textContent = 'Automatic capture is unavailable in this browser. Use Take photo instead.';
|
|
328
|
-
setFeedbackState(feedback, 'manual');
|
|
329
|
-
}
|
|
330
|
-
finally {
|
|
331
|
-
analysisInFlight = false;
|
|
332
|
-
}
|
|
333
|
-
scheduleAutoCapture();
|
|
334
|
-
});
|
|
335
|
-
}
|
|
285
|
+
video.srcObject = stream;
|
|
286
|
+
await controller?.start();
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
finalize(null, error instanceof Error ? error : new Error('Failed to open the camera capture UI.'));
|
|
290
|
+
}
|
|
291
|
+
})();
|
|
336
292
|
}
|
|
337
293
|
catch (error) {
|
|
338
294
|
finalize(null, error instanceof Error ? error : new Error('Failed to open the camera capture UI.'));
|
|
339
295
|
}
|
|
340
296
|
});
|
|
341
297
|
}
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
298
|
+
function renderPopupState(options) {
|
|
299
|
+
const { state, mediaContainer, video, title, copy, feedback, actions, cancelButton, captureButton, confirmButton, retakeButton, guideOverlay, setPreviewUrl, } = options;
|
|
300
|
+
guideOverlay.setProgress(state.phase === 'live' && state.mode === 'auto' ? state.countdownProgress : 0);
|
|
301
|
+
if (state.phase === 'preview') {
|
|
302
|
+
const nextPreviewUrl = URL.createObjectURL(state.previewBlob);
|
|
303
|
+
setPreviewUrl(nextPreviewUrl);
|
|
304
|
+
const image = document.createElement('img');
|
|
305
|
+
image.alt = 'Captured face preview';
|
|
306
|
+
image.src = nextPreviewUrl;
|
|
307
|
+
applyStyles(image, {
|
|
308
|
+
position: 'absolute',
|
|
309
|
+
inset: '0',
|
|
310
|
+
zIndex: '1',
|
|
311
|
+
width: '100%',
|
|
312
|
+
height: '100%',
|
|
313
|
+
objectFit: 'cover',
|
|
314
|
+
transform: 'scaleX(-1)',
|
|
353
315
|
});
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
window.removeEventListener('focus', handleFocus);
|
|
365
|
-
input.remove();
|
|
316
|
+
mediaContainer.replaceChildren(video, image);
|
|
317
|
+
title.textContent = 'Review your photo';
|
|
318
|
+
copy.textContent = state.qualityResult?.passesQualityChecks === false
|
|
319
|
+
? 'The capture did not pass the checks. Retake the photo.'
|
|
320
|
+
: 'Confirm this photo or retake it.';
|
|
321
|
+
feedback.textContent = state.feedbackMessage;
|
|
322
|
+
setFeedbackState(feedback, state.feedbackTone);
|
|
323
|
+
actions.replaceChildren(cancelButton, retakeButton);
|
|
324
|
+
if (state.canConfirm) {
|
|
325
|
+
actions.append(confirmButton);
|
|
366
326
|
}
|
|
367
|
-
document.body.appendChild(input);
|
|
368
|
-
input.click();
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
function prefersNativeCameraCapture() {
|
|
372
|
-
return /WhatsApp/i.test(navigator.userAgent);
|
|
373
|
-
}
|
|
374
|
-
async function waitForVideoReady(video) {
|
|
375
|
-
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
376
|
-
await video.play();
|
|
377
327
|
return;
|
|
378
328
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const handleError = () => {
|
|
385
|
-
cleanup();
|
|
386
|
-
reject(new Error('Failed to start the camera preview.'));
|
|
387
|
-
};
|
|
388
|
-
const cleanup = () => {
|
|
389
|
-
video.removeEventListener('loadedmetadata', handleReady);
|
|
390
|
-
video.removeEventListener('error', handleError);
|
|
391
|
-
};
|
|
392
|
-
video.addEventListener('loadedmetadata', handleReady, { once: true });
|
|
393
|
-
video.addEventListener('error', handleError, { once: true });
|
|
394
|
-
});
|
|
395
|
-
await video.play();
|
|
396
|
-
}
|
|
397
|
-
function captureVideoFrame(video) {
|
|
398
|
-
if (!video.videoWidth || !video.videoHeight) {
|
|
399
|
-
return Promise.reject(new Error('Camera preview is not ready yet.'));
|
|
400
|
-
}
|
|
401
|
-
const canvas = document.createElement('canvas');
|
|
402
|
-
canvas.width = video.videoWidth;
|
|
403
|
-
canvas.height = video.videoHeight;
|
|
404
|
-
const context = canvas.getContext('2d');
|
|
405
|
-
if (!context) {
|
|
406
|
-
return Promise.reject(new Error('Failed to initialize camera capture.'));
|
|
407
|
-
}
|
|
408
|
-
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
409
|
-
return new Promise((resolve, reject) => {
|
|
410
|
-
canvas.toBlob((blob) => {
|
|
411
|
-
if (!blob) {
|
|
412
|
-
reject(new Error('Failed to capture an image.'));
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
resolve(blob);
|
|
416
|
-
}, 'image/jpeg', 0.92);
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
function resumeVideoPreview(video) {
|
|
420
|
-
void video.play().catch(() => {
|
|
421
|
-
// Ignore resume failures here; capture flow already handles preview startup errors.
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
async function assessCapturedBlobSafely(blob) {
|
|
425
|
-
try {
|
|
426
|
-
const image = await blobToImage(blob);
|
|
427
|
-
return await assessFaceQuality(image);
|
|
329
|
+
mediaContainer.replaceChildren(video, guideOverlay.wrapper);
|
|
330
|
+
setPreviewUrl('');
|
|
331
|
+
if (state.mode === 'auto') {
|
|
332
|
+
title.textContent = state.phase === 'starting' ? 'Opening camera...' : 'Center your face';
|
|
333
|
+
copy.textContent = 'Keep your face inside the oval. We will start a short countdown when the framing looks good and keep the best frame.';
|
|
428
334
|
}
|
|
429
|
-
|
|
430
|
-
|
|
335
|
+
else {
|
|
336
|
+
title.textContent = state.phase === 'starting' ? 'Opening camera...' : 'Take a face photo';
|
|
337
|
+
copy.textContent = 'Line up your face in the oval, then take a photo manually.';
|
|
431
338
|
}
|
|
339
|
+
feedback.textContent = state.phase === 'starting'
|
|
340
|
+
? 'Requesting camera access...'
|
|
341
|
+
: state.feedbackMessage;
|
|
342
|
+
setFeedbackState(feedback, state.phase === 'starting'
|
|
343
|
+
? 'neutral'
|
|
344
|
+
: state.feedbackTone);
|
|
345
|
+
captureButton.style.display = state.phase === 'live' && state.mode === 'manual'
|
|
346
|
+
? 'inline-flex'
|
|
347
|
+
: 'none';
|
|
348
|
+
captureButton.disabled = !(state.phase === 'live' && state.mode === 'manual');
|
|
349
|
+
actions.replaceChildren(cancelButton, captureButton);
|
|
432
350
|
}
|
|
433
351
|
function createActionButton(label, variant) {
|
|
434
352
|
const button = document.createElement('button');
|
|
@@ -455,20 +373,50 @@ function createGuideOverlay() {
|
|
|
455
373
|
inset: '0',
|
|
456
374
|
pointerEvents: 'none',
|
|
457
375
|
});
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
transform: 'translate(-50%, -50%)',
|
|
466
|
-
borderRadius: '999px',
|
|
467
|
-
border: '3px solid rgba(255, 255, 255, 0.9)',
|
|
468
|
-
boxShadow: '0 0 0 9999px rgba(2, 6, 23, 0.34)',
|
|
376
|
+
const svg = createSvgElement('svg');
|
|
377
|
+
svg.setAttribute('viewBox', '0 0 100 100');
|
|
378
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
379
|
+
applyStyles(svg, {
|
|
380
|
+
width: '100%',
|
|
381
|
+
height: '100%',
|
|
382
|
+
display: 'block',
|
|
469
383
|
});
|
|
470
|
-
|
|
471
|
-
|
|
384
|
+
const mask = createSvgElement('path');
|
|
385
|
+
mask.setAttribute('d', CAPTURE_GUIDE_MASK_PATH);
|
|
386
|
+
mask.setAttribute('fill', 'rgba(51, 65, 85, 0.75)');
|
|
387
|
+
mask.setAttribute('fill-rule', 'evenodd');
|
|
388
|
+
const outline = createSvgElement('path');
|
|
389
|
+
outline.setAttribute('d', CAPTURE_GUIDE_PATH);
|
|
390
|
+
outline.setAttribute('fill', 'none');
|
|
391
|
+
outline.setAttribute('stroke', 'rgba(255, 255, 255, 0.92)');
|
|
392
|
+
outline.setAttribute('stroke-width', '2.8');
|
|
393
|
+
outline.setAttribute('stroke-linecap', 'round');
|
|
394
|
+
outline.setAttribute('stroke-linejoin', 'round');
|
|
395
|
+
const progress = createSvgElement('path');
|
|
396
|
+
progress.setAttribute('d', CAPTURE_GUIDE_PATH);
|
|
397
|
+
progress.setAttribute('fill', 'none');
|
|
398
|
+
progress.setAttribute('stroke', '#22c55e');
|
|
399
|
+
progress.setAttribute('stroke-width', '2.8');
|
|
400
|
+
progress.setAttribute('stroke-linecap', 'round');
|
|
401
|
+
progress.setAttribute('stroke-linejoin', 'round');
|
|
402
|
+
progress.setAttribute('pathLength', '100');
|
|
403
|
+
progress.setAttribute('stroke-dasharray', '100');
|
|
404
|
+
progress.setAttribute('stroke-dashoffset', '100');
|
|
405
|
+
progress.style.transition = 'stroke-dashoffset 0.14s linear, opacity 0.14s linear';
|
|
406
|
+
progress.style.opacity = '0';
|
|
407
|
+
svg.append(mask, outline, progress);
|
|
408
|
+
wrapper.append(svg);
|
|
409
|
+
return {
|
|
410
|
+
wrapper,
|
|
411
|
+
setProgress(value) {
|
|
412
|
+
const progressValue = Math.min(Math.max(value, 0), 1);
|
|
413
|
+
progress.setAttribute('stroke-dashoffset', `${100 - progressValue * 100}`);
|
|
414
|
+
progress.style.opacity = progressValue > 0 ? '1' : '0';
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function createSvgElement(tagName) {
|
|
419
|
+
return document.createElementNS('http://www.w3.org/2000/svg', tagName);
|
|
472
420
|
}
|
|
473
421
|
function setFeedbackState(element, state) {
|
|
474
422
|
switch (state) {
|
|
@@ -500,52 +448,5 @@ function setFeedbackState(element, state) {
|
|
|
500
448
|
function applyStyles(element, styles) {
|
|
501
449
|
Object.assign(element.style, styles);
|
|
502
450
|
}
|
|
503
|
-
|
|
504
|
-
if (error instanceof DOMException) {
|
|
505
|
-
switch (error.name) {
|
|
506
|
-
case 'NotAllowedError':
|
|
507
|
-
case 'SecurityError':
|
|
508
|
-
return 'Camera access was denied. Allow camera access and try again.';
|
|
509
|
-
case 'NotFoundError':
|
|
510
|
-
return 'No camera was found on this device.';
|
|
511
|
-
case 'NotReadableError':
|
|
512
|
-
return 'The camera is already in use by another application.';
|
|
513
|
-
default:
|
|
514
|
-
return error.message || 'Failed to access the camera.';
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (error instanceof Error) {
|
|
518
|
-
return error.message;
|
|
519
|
-
}
|
|
520
|
-
return 'Failed to access the camera.';
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* Loads a Blob as an HTMLImageElement for face detection analysis.
|
|
524
|
-
*/
|
|
525
|
-
export function blobToImage(blob) {
|
|
526
|
-
return new Promise((resolve, reject) => {
|
|
527
|
-
const url = URL.createObjectURL(blob);
|
|
528
|
-
const img = new Image();
|
|
529
|
-
img.onload = () => {
|
|
530
|
-
URL.revokeObjectURL(url);
|
|
531
|
-
resolve(img);
|
|
532
|
-
};
|
|
533
|
-
img.onerror = () => {
|
|
534
|
-
URL.revokeObjectURL(url);
|
|
535
|
-
reject(new Error('Failed to load captured image'));
|
|
536
|
-
};
|
|
537
|
-
img.src = url;
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Creates a data URL from a Blob for display in an <img> tag.
|
|
542
|
-
*/
|
|
543
|
-
export function blobToDataURL(blob) {
|
|
544
|
-
return new Promise((resolve, reject) => {
|
|
545
|
-
const reader = new FileReader();
|
|
546
|
-
reader.onload = () => resolve(reader.result);
|
|
547
|
-
reader.onerror = () => reject(new Error('Failed to read image'));
|
|
548
|
-
reader.readAsDataURL(blob);
|
|
549
|
-
});
|
|
550
|
-
}
|
|
451
|
+
export { blobToImage, blobToDataURL };
|
|
551
452
|
//# sourceMappingURL=camera.js.map
|