@simprints/simface-sdk 0.6.1 → 0.8.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 +25 -31
- package/dist/components/simface-capture.js +210 -326
- 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 +126 -574
- 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/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 +2632 -2677
- package/dist/simface-sdk.umd.cjs +43 -59
- package/dist/types/index.d.ts +17 -0
- package/package.json +1 -1
package/dist/services/camera.js
CHANGED
|
@@ -1,95 +1,130 @@
|
|
|
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 {
|
|
8
|
-
import {
|
|
7
|
+
import { normalizeCaptureOptions, } from '../shared/capture-flow.js';
|
|
8
|
+
import { blobToDataURL, blobToImage, captureFromFileInput, } from '../shared/capture-runtime.js';
|
|
9
9
|
const CAPTURE_DIALOG_Z_INDEX = '2147483647';
|
|
10
10
|
/**
|
|
11
|
-
* Opens the
|
|
11
|
+
* Opens the configured capture presentation and returns a confirmed image Blob,
|
|
12
|
+
* or null if the user cancels.
|
|
12
13
|
*/
|
|
13
|
-
export async function captureFromCamera() {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
export async function captureFromCamera(options) {
|
|
15
|
+
const captureOptions = normalizeCaptureOptions(options);
|
|
16
|
+
if (captureOptions.presentation === 'embedded') {
|
|
17
|
+
return captureFromEmbeddedComponent(captureOptions);
|
|
18
|
+
}
|
|
19
|
+
// For popup, skip the expensive auto-capture probe (MediaPipe load) here.
|
|
20
|
+
// We only need to know if a camera API is available; the full capabilities
|
|
21
|
+
// resolution (including auto-capture) happens inside the component once the
|
|
22
|
+
// UI is shown. If there is no camera API, bypass the popup and go directly
|
|
23
|
+
// to the media-picker fallback.
|
|
24
|
+
const supportsMediaDevices = typeof navigator.mediaDevices?.getUserMedia === 'function';
|
|
25
|
+
if (!supportsMediaDevices) {
|
|
26
|
+
if (captureOptions.allowMediaPickerFallback) {
|
|
27
|
+
return captureFromFileInput();
|
|
28
|
+
}
|
|
29
|
+
throw new Error('No supported capture strategy is available in this environment.');
|
|
30
|
+
}
|
|
31
|
+
return captureFromPopupCamera(captureOptions);
|
|
32
|
+
}
|
|
33
|
+
async function captureFromEmbeddedComponent(options) {
|
|
34
|
+
await import('../components/simface-capture.js');
|
|
35
|
+
const host = resolveEmbeddedCaptureHost(options.container);
|
|
36
|
+
const usingExistingElement = host.tagName.toLowerCase() === 'simface-capture';
|
|
37
|
+
const element = (usingExistingElement
|
|
38
|
+
? host
|
|
39
|
+
: document.createElement('simface-capture'));
|
|
40
|
+
if (!usingExistingElement) {
|
|
41
|
+
host.appendChild(element);
|
|
42
|
+
}
|
|
43
|
+
element.embedded = true;
|
|
44
|
+
element.label = options.label;
|
|
45
|
+
element.confirmLabel = options.confirmLabel;
|
|
46
|
+
element.capturePreference = options.capturePreference;
|
|
47
|
+
element.allowMediaPickerFallback = options.allowMediaPickerFallback;
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
element.removeEventListener('simface-captured', handleCaptured);
|
|
51
|
+
element.removeEventListener('simface-cancelled', handleCancelled);
|
|
52
|
+
element.removeEventListener('simface-error', handleError);
|
|
53
|
+
element.active = false;
|
|
54
|
+
if (!usingExistingElement) {
|
|
55
|
+
element.remove();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const handleCaptured = (event) => {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve(event.detail.imageBlob);
|
|
61
|
+
};
|
|
62
|
+
const handleCancelled = () => {
|
|
63
|
+
cleanup();
|
|
64
|
+
resolve(null);
|
|
65
|
+
};
|
|
66
|
+
const handleError = (event) => {
|
|
67
|
+
cleanup();
|
|
68
|
+
reject(new Error(event.detail.error));
|
|
69
|
+
};
|
|
70
|
+
element.addEventListener('simface-captured', handleCaptured);
|
|
71
|
+
element.addEventListener('simface-cancelled', handleCancelled);
|
|
72
|
+
element.addEventListener('simface-error', handleError);
|
|
73
|
+
void element.startCapture().catch((error) => {
|
|
74
|
+
cleanup();
|
|
75
|
+
reject(error instanceof Error ? error : new Error('Embedded capture failed.'));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
22
78
|
}
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
return false;
|
|
79
|
+
function resolveEmbeddedCaptureHost(container) {
|
|
80
|
+
if (!container) {
|
|
81
|
+
throw new Error('Embedded capture requires a container element or selector.');
|
|
27
82
|
}
|
|
28
|
-
if (
|
|
29
|
-
return
|
|
83
|
+
if (container instanceof HTMLElement) {
|
|
84
|
+
return container;
|
|
30
85
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return false;
|
|
86
|
+
const element = document.querySelector(container);
|
|
87
|
+
if (!element) {
|
|
88
|
+
throw new Error(`No element matched the embedded capture container selector "${container}".`);
|
|
37
89
|
}
|
|
90
|
+
return element;
|
|
38
91
|
}
|
|
39
|
-
async function
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
stream = await navigator.mediaDevices.getUserMedia({
|
|
43
|
-
video: { facingMode: { ideal: 'user' } },
|
|
44
|
-
audio: false,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
throw new Error(describeCameraError(error));
|
|
49
|
-
}
|
|
50
|
-
let streamStopped = false;
|
|
51
|
-
const stopStream = () => {
|
|
52
|
-
if (streamStopped) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
streamStopped = true;
|
|
56
|
-
stream.getTracks().forEach((track) => track.stop());
|
|
57
|
-
};
|
|
92
|
+
async function captureFromPopupCamera(options) {
|
|
93
|
+
await import('../components/simface-capture.js');
|
|
58
94
|
return new Promise((resolve, reject) => {
|
|
59
95
|
let settled = false;
|
|
60
|
-
let mode = initialMode;
|
|
61
|
-
let overlay = null;
|
|
62
|
-
let previewUrl = '';
|
|
63
|
-
let previewBlob = null;
|
|
64
|
-
let animationFrameId = null;
|
|
65
96
|
let escapeHandler = null;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
};
|
|
97
|
+
const overlay = document.createElement('div');
|
|
98
|
+
overlay.setAttribute('data-simface-camera-overlay', 'true');
|
|
99
|
+
overlay.setAttribute('role', 'dialog');
|
|
100
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
101
|
+
applyStyles(overlay, {
|
|
102
|
+
position: 'fixed',
|
|
103
|
+
inset: '0',
|
|
104
|
+
zIndex: CAPTURE_DIALOG_Z_INDEX,
|
|
105
|
+
display: 'flex',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
justifyContent: 'center',
|
|
108
|
+
padding: '24px',
|
|
109
|
+
background: 'rgba(15, 23, 42, 0.82)',
|
|
110
|
+
colorScheme: 'light',
|
|
111
|
+
});
|
|
112
|
+
const element = document.createElement('simface-capture');
|
|
113
|
+
element.label = options.label;
|
|
114
|
+
element.confirmLabel = options.confirmLabel;
|
|
115
|
+
element.capturePreference = options.capturePreference;
|
|
116
|
+
element.allowMediaPickerFallback = options.allowMediaPickerFallback;
|
|
117
|
+
overlay.appendChild(element);
|
|
118
|
+
document.body.appendChild(overlay);
|
|
81
119
|
const cleanup = () => {
|
|
82
|
-
if (animationFrameId !== null) {
|
|
83
|
-
window.cancelAnimationFrame(animationFrameId);
|
|
84
|
-
}
|
|
85
120
|
if (escapeHandler) {
|
|
86
121
|
window.removeEventListener('keydown', escapeHandler);
|
|
87
122
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
overlay
|
|
123
|
+
element.removeEventListener('simface-captured', handleCaptured);
|
|
124
|
+
element.removeEventListener('simface-cancelled', handleCancelled);
|
|
125
|
+
element.removeEventListener('simface-error', handleError);
|
|
126
|
+
element.active = false;
|
|
127
|
+
overlay.remove();
|
|
93
128
|
};
|
|
94
129
|
const finalize = (value, error) => {
|
|
95
130
|
if (settled) {
|
|
@@ -103,514 +138,31 @@ async function captureFromMediaDevices(initialMode) {
|
|
|
103
138
|
}
|
|
104
139
|
resolve(value);
|
|
105
140
|
};
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
resetAutoCaptureState(guideOverlay);
|
|
109
|
-
if (previewUrl) {
|
|
110
|
-
URL.revokeObjectURL(previewUrl);
|
|
111
|
-
previewUrl = '';
|
|
112
|
-
}
|
|
113
|
-
previewBlob = null;
|
|
114
|
-
mediaContainer.replaceChildren(video, guideOverlay.wrapper);
|
|
115
|
-
if (videoReady) {
|
|
116
|
-
resumeVideoPreview(video);
|
|
117
|
-
}
|
|
118
|
-
title.textContent = mode === 'auto' ? 'Center your face' : 'Take a face photo';
|
|
119
|
-
copy.textContent =
|
|
120
|
-
mode === 'auto'
|
|
121
|
-
? 'Keep your face inside the oval. We will start a short countdown when the framing looks good and keep the best frame.'
|
|
122
|
-
: 'Line up your face in the oval, then take a photo manually.';
|
|
123
|
-
feedback.textContent =
|
|
124
|
-
mode === 'auto'
|
|
125
|
-
? 'Looking for a single face in frame...'
|
|
126
|
-
: 'When you are ready, press Take photo.';
|
|
127
|
-
setFeedbackState(feedback, mode === 'auto' ? 'neutral' : 'manual');
|
|
128
|
-
captureButton.style.display = mode === 'manual' ? 'inline-flex' : 'none';
|
|
129
|
-
captureButton.disabled = true;
|
|
130
|
-
actions.replaceChildren(cancelButton, captureButton);
|
|
131
|
-
};
|
|
132
|
-
const renderPreviewMode = (video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, qualityResult, options) => {
|
|
133
|
-
previewActive = true;
|
|
134
|
-
countdownStartedAt = null;
|
|
135
|
-
if (previewUrl) {
|
|
136
|
-
URL.revokeObjectURL(previewUrl);
|
|
137
|
-
}
|
|
138
|
-
previewUrl = URL.createObjectURL(blob);
|
|
139
|
-
previewBlob = blob;
|
|
140
|
-
const image = document.createElement('img');
|
|
141
|
-
image.alt = 'Captured face preview';
|
|
142
|
-
image.src = previewUrl;
|
|
143
|
-
applyStyles(image, {
|
|
144
|
-
position: 'absolute',
|
|
145
|
-
inset: '0',
|
|
146
|
-
zIndex: '1',
|
|
147
|
-
width: '100%',
|
|
148
|
-
height: '100%',
|
|
149
|
-
objectFit: 'cover',
|
|
150
|
-
});
|
|
151
|
-
mediaContainer.replaceChildren(video, image);
|
|
152
|
-
title.textContent = 'Review your photo';
|
|
153
|
-
copy.textContent =
|
|
154
|
-
options?.copyText
|
|
155
|
-
?? (qualityResult?.passesQualityChecks === false
|
|
156
|
-
? 'The capture did not pass the checks. Retake the photo.'
|
|
157
|
-
: 'Confirm this photo or retake it.');
|
|
158
|
-
feedback.textContent =
|
|
159
|
-
options?.feedbackText
|
|
160
|
-
?? qualityResult?.message
|
|
161
|
-
?? 'Review the captured image before continuing.';
|
|
162
|
-
setFeedbackState(feedback, options?.feedbackState
|
|
163
|
-
?? (qualityResult
|
|
164
|
-
? qualityResult.passesQualityChecks
|
|
165
|
-
? 'success'
|
|
166
|
-
: 'error'
|
|
167
|
-
: 'manual'));
|
|
168
|
-
actions.replaceChildren(cancelButton, retakeButton);
|
|
169
|
-
if (qualityResult?.passesQualityChecks !== false) {
|
|
170
|
-
actions.append(confirmButton);
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
try {
|
|
174
|
-
overlay = document.createElement('div');
|
|
175
|
-
overlay.setAttribute('data-simface-camera-overlay', 'true');
|
|
176
|
-
overlay.setAttribute('role', 'dialog');
|
|
177
|
-
overlay.setAttribute('aria-modal', 'true');
|
|
178
|
-
applyStyles(overlay, {
|
|
179
|
-
position: 'fixed',
|
|
180
|
-
inset: '0',
|
|
181
|
-
zIndex: CAPTURE_DIALOG_Z_INDEX,
|
|
182
|
-
display: 'flex',
|
|
183
|
-
alignItems: 'center',
|
|
184
|
-
justifyContent: 'center',
|
|
185
|
-
padding: '24px',
|
|
186
|
-
background: 'rgba(15, 23, 42, 0.82)',
|
|
187
|
-
});
|
|
188
|
-
const panel = document.createElement('div');
|
|
189
|
-
applyStyles(panel, {
|
|
190
|
-
width: 'min(100%, 560px)',
|
|
191
|
-
display: 'flex',
|
|
192
|
-
flexDirection: 'column',
|
|
193
|
-
gap: '16px',
|
|
194
|
-
padding: '20px',
|
|
195
|
-
borderRadius: '20px',
|
|
196
|
-
background: '#020617',
|
|
197
|
-
color: '#e2e8f0',
|
|
198
|
-
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.35)',
|
|
199
|
-
});
|
|
200
|
-
const title = document.createElement('h2');
|
|
201
|
-
applyStyles(title, {
|
|
202
|
-
margin: '0',
|
|
203
|
-
fontSize: '1.25rem',
|
|
204
|
-
fontWeight: '700',
|
|
205
|
-
});
|
|
206
|
-
const copy = document.createElement('p');
|
|
207
|
-
applyStyles(copy, {
|
|
208
|
-
margin: '0',
|
|
209
|
-
color: '#cbd5e1',
|
|
210
|
-
fontSize: '0.95rem',
|
|
211
|
-
lineHeight: '1.5',
|
|
212
|
-
});
|
|
213
|
-
const mediaContainer = document.createElement('div');
|
|
214
|
-
applyStyles(mediaContainer, {
|
|
215
|
-
position: 'relative',
|
|
216
|
-
overflow: 'hidden',
|
|
217
|
-
width: '100%',
|
|
218
|
-
aspectRatio: '3 / 4',
|
|
219
|
-
minHeight: '320px',
|
|
220
|
-
borderRadius: '18px',
|
|
221
|
-
background: '#000',
|
|
222
|
-
});
|
|
223
|
-
const video = document.createElement('video');
|
|
224
|
-
video.autoplay = true;
|
|
225
|
-
video.muted = true;
|
|
226
|
-
video.playsInline = true;
|
|
227
|
-
video.srcObject = stream;
|
|
228
|
-
applyStyles(video, {
|
|
229
|
-
width: '100%',
|
|
230
|
-
height: '100%',
|
|
231
|
-
objectFit: 'cover',
|
|
232
|
-
});
|
|
233
|
-
const feedback = document.createElement('div');
|
|
234
|
-
applyStyles(feedback, {
|
|
235
|
-
borderRadius: '14px',
|
|
236
|
-
padding: '12px 14px',
|
|
237
|
-
font: '600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
238
|
-
});
|
|
239
|
-
const actions = document.createElement('div');
|
|
240
|
-
applyStyles(actions, {
|
|
241
|
-
display: 'flex',
|
|
242
|
-
flexWrap: 'wrap',
|
|
243
|
-
gap: '12px',
|
|
244
|
-
justifyContent: 'flex-end',
|
|
245
|
-
});
|
|
246
|
-
const cancelButton = createActionButton('Cancel', 'secondary');
|
|
247
|
-
cancelButton.dataset.simfaceAction = 'cancel';
|
|
248
|
-
const captureButton = createActionButton('Take photo', 'primary');
|
|
249
|
-
captureButton.dataset.simfaceAction = 'capture';
|
|
250
|
-
captureButton.style.display = mode === 'manual' ? 'inline-flex' : 'none';
|
|
251
|
-
captureButton.disabled = true;
|
|
252
|
-
const guideOverlay = createGuideOverlay();
|
|
253
|
-
const confirmButton = createActionButton('Use photo', 'primary');
|
|
254
|
-
confirmButton.dataset.simfaceAction = 'confirm';
|
|
255
|
-
const retakeButton = createActionButton('Retake', 'secondary');
|
|
256
|
-
retakeButton.dataset.simfaceAction = 'retake';
|
|
257
|
-
panel.append(title, copy, mediaContainer, feedback, actions);
|
|
258
|
-
overlay.append(panel);
|
|
259
|
-
document.body.appendChild(overlay);
|
|
260
|
-
renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton, guideOverlay);
|
|
261
|
-
escapeHandler = (event) => {
|
|
262
|
-
if (event.key === 'Escape') {
|
|
263
|
-
finalize(null);
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
cancelButton.addEventListener('click', () => finalize(null));
|
|
267
|
-
confirmButton.addEventListener('click', async () => {
|
|
268
|
-
if (!previewBlob) {
|
|
269
|
-
finalize(null, new Error('Failed to confirm the photo.'));
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
finalize(previewBlob);
|
|
273
|
-
});
|
|
274
|
-
retakeButton.addEventListener('click', () => {
|
|
275
|
-
renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton, guideOverlay);
|
|
276
|
-
if (mode === 'manual' && videoReady) {
|
|
277
|
-
captureButton.disabled = false;
|
|
278
|
-
}
|
|
279
|
-
if (mode === 'auto') {
|
|
280
|
-
scheduleAutoCapture();
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
captureButton.addEventListener('click', async () => {
|
|
284
|
-
if (previewActive) {
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
const blob = await captureVideoFrame(video);
|
|
289
|
-
const qualityResult = await assessCapturedBlobSafely(blob);
|
|
290
|
-
renderPreviewMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, qualityResult);
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
finalize(null, error instanceof Error ? error : new Error('Failed to capture an image.'));
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
window.addEventListener('keydown', escapeHandler);
|
|
297
|
-
waitForVideoReady(video)
|
|
298
|
-
.then(() => {
|
|
299
|
-
videoReady = true;
|
|
300
|
-
if (mode === 'manual') {
|
|
301
|
-
captureButton.disabled = false;
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
scheduleAutoCapture();
|
|
305
|
-
})
|
|
306
|
-
.catch((error) => {
|
|
307
|
-
finalize(null, error instanceof Error ? error : new Error('Failed to start the camera preview.'));
|
|
308
|
-
});
|
|
309
|
-
function scheduleAutoCapture() {
|
|
310
|
-
if (settled || previewActive || mode !== 'auto') {
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
animationFrameId = window.requestAnimationFrame(async (timestamp) => {
|
|
314
|
-
if (previewActive ||
|
|
315
|
-
analysisInFlight ||
|
|
316
|
-
timestamp - lastAnalysisTimestamp < AUTO_CAPTURE_ANALYSIS_INTERVAL_MS) {
|
|
317
|
-
scheduleAutoCapture();
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
lastAnalysisTimestamp = timestamp;
|
|
321
|
-
analysisInFlight = true;
|
|
322
|
-
try {
|
|
323
|
-
const qualityResult = await assessFaceQualityForVideo(video, timestamp);
|
|
324
|
-
if (qualityResult.passesQualityChecks) {
|
|
325
|
-
if (countdownStartedAt === null) {
|
|
326
|
-
countdownStartedAt = timestamp;
|
|
327
|
-
}
|
|
328
|
-
if (qualityResult.captureScore > bestCaptureScore) {
|
|
329
|
-
bestCaptureBlob = await captureVideoFrame(video);
|
|
330
|
-
bestCaptureScore = qualityResult.captureScore;
|
|
331
|
-
bestQualityResult = qualityResult;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (countdownStartedAt !== null) {
|
|
335
|
-
const countdownProgress = Math.min((timestamp - countdownStartedAt) / AUTO_CAPTURE_COUNTDOWN_MS, 1);
|
|
336
|
-
guideOverlay.setProgress(countdownProgress);
|
|
337
|
-
feedback.textContent = autoCaptureCountdownMessage(timestamp, countdownStartedAt, qualityResult);
|
|
338
|
-
setFeedbackState(feedback, qualityResult.passesQualityChecks ? 'success' : 'neutral');
|
|
339
|
-
if (countdownProgress >= 1) {
|
|
340
|
-
const blob = bestCaptureBlob ?? await captureVideoFrame(video);
|
|
341
|
-
const previewQualityResult = bestCaptureBlob && bestQualityResult
|
|
342
|
-
? bestQualityResult
|
|
343
|
-
: await assessCapturedBlobSafely(blob);
|
|
344
|
-
renderPreviewMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, confirmButton, retakeButton, blob, previewQualityResult, {
|
|
345
|
-
copyText: 'Best frame captured. Review and confirm this photo.',
|
|
346
|
-
feedbackText: autoCaptureCompleteMessage(previewQualityResult),
|
|
347
|
-
feedbackState: previewQualityResult?.passesQualityChecks === false ? 'error' : 'success',
|
|
348
|
-
});
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
else {
|
|
353
|
-
guideOverlay.setProgress(0);
|
|
354
|
-
feedback.textContent = qualityResult.message;
|
|
355
|
-
setFeedbackState(feedback, qualityResult.passesQualityChecks ? 'success' : 'neutral');
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
catch (error) {
|
|
359
|
-
mode = 'manual';
|
|
360
|
-
renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton, guideOverlay);
|
|
361
|
-
captureButton.disabled = false;
|
|
362
|
-
feedback.textContent = 'Automatic capture is unavailable in this browser. Use Take photo instead.';
|
|
363
|
-
setFeedbackState(feedback, 'manual');
|
|
364
|
-
}
|
|
365
|
-
finally {
|
|
366
|
-
analysisInFlight = false;
|
|
367
|
-
}
|
|
368
|
-
scheduleAutoCapture();
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
catch (error) {
|
|
373
|
-
finalize(null, error instanceof Error ? error : new Error('Failed to open the camera capture UI.'));
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
function captureFromFileInput() {
|
|
378
|
-
return new Promise((resolve) => {
|
|
379
|
-
const input = document.createElement('input');
|
|
380
|
-
input.type = 'file';
|
|
381
|
-
input.accept = 'image/*';
|
|
382
|
-
input.capture = 'user';
|
|
383
|
-
input.style.display = 'none';
|
|
384
|
-
input.addEventListener('change', () => {
|
|
385
|
-
const file = input.files?.[0] ?? null;
|
|
386
|
-
cleanup();
|
|
387
|
-
resolve(file);
|
|
388
|
-
});
|
|
389
|
-
const handleFocus = () => {
|
|
390
|
-
setTimeout(() => {
|
|
391
|
-
if (!input.files?.length) {
|
|
392
|
-
cleanup();
|
|
393
|
-
resolve(null);
|
|
394
|
-
}
|
|
395
|
-
}, 500);
|
|
396
|
-
};
|
|
397
|
-
window.addEventListener('focus', handleFocus, { once: true });
|
|
398
|
-
function cleanup() {
|
|
399
|
-
window.removeEventListener('focus', handleFocus);
|
|
400
|
-
input.remove();
|
|
401
|
-
}
|
|
402
|
-
document.body.appendChild(input);
|
|
403
|
-
input.click();
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
function prefersNativeCameraCapture() {
|
|
407
|
-
return /WhatsApp/i.test(navigator.userAgent);
|
|
408
|
-
}
|
|
409
|
-
async function waitForVideoReady(video) {
|
|
410
|
-
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
411
|
-
await video.play();
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
await new Promise((resolve, reject) => {
|
|
415
|
-
const handleReady = () => {
|
|
416
|
-
cleanup();
|
|
417
|
-
resolve();
|
|
141
|
+
const handleCaptured = (event) => {
|
|
142
|
+
finalize(event.detail.imageBlob);
|
|
418
143
|
};
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
reject(new Error('Failed to start the camera preview.'));
|
|
144
|
+
const handleCancelled = () => {
|
|
145
|
+
finalize(null);
|
|
422
146
|
};
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
video.removeEventListener('error', handleError);
|
|
147
|
+
const handleError = (event) => {
|
|
148
|
+
finalize(null, new Error(event.detail.error));
|
|
426
149
|
};
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
await video.play();
|
|
431
|
-
}
|
|
432
|
-
function captureVideoFrame(video) {
|
|
433
|
-
if (!video.videoWidth || !video.videoHeight) {
|
|
434
|
-
return Promise.reject(new Error('Camera preview is not ready yet.'));
|
|
435
|
-
}
|
|
436
|
-
const canvas = document.createElement('canvas');
|
|
437
|
-
canvas.width = video.videoWidth;
|
|
438
|
-
canvas.height = video.videoHeight;
|
|
439
|
-
const context = canvas.getContext('2d');
|
|
440
|
-
if (!context) {
|
|
441
|
-
return Promise.reject(new Error('Failed to initialize camera capture.'));
|
|
442
|
-
}
|
|
443
|
-
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
444
|
-
return new Promise((resolve, reject) => {
|
|
445
|
-
canvas.toBlob((blob) => {
|
|
446
|
-
if (!blob) {
|
|
447
|
-
reject(new Error('Failed to capture an image.'));
|
|
448
|
-
return;
|
|
150
|
+
escapeHandler = (event) => {
|
|
151
|
+
if (event.key === 'Escape') {
|
|
152
|
+
finalize(null);
|
|
449
153
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
async function assessCapturedBlobSafely(blob) {
|
|
460
|
-
try {
|
|
461
|
-
const image = await blobToImage(blob);
|
|
462
|
-
return await assessFaceQuality(image);
|
|
463
|
-
}
|
|
464
|
-
catch {
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
function createActionButton(label, variant) {
|
|
469
|
-
const button = document.createElement('button');
|
|
470
|
-
button.type = 'button';
|
|
471
|
-
button.textContent = label;
|
|
472
|
-
applyStyles(button, {
|
|
473
|
-
display: 'inline-flex',
|
|
474
|
-
alignItems: 'center',
|
|
475
|
-
justifyContent: 'center',
|
|
476
|
-
border: 'none',
|
|
477
|
-
borderRadius: '999px',
|
|
478
|
-
padding: '12px 18px',
|
|
479
|
-
font: '600 15px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
480
|
-
cursor: 'pointer',
|
|
481
|
-
color: variant === 'primary' ? '#fff' : '#0f172a',
|
|
482
|
-
background: variant === 'primary' ? '#2563eb' : '#e2e8f0',
|
|
483
|
-
});
|
|
484
|
-
return button;
|
|
485
|
-
}
|
|
486
|
-
function createGuideOverlay() {
|
|
487
|
-
const wrapper = document.createElement('div');
|
|
488
|
-
applyStyles(wrapper, {
|
|
489
|
-
position: 'absolute',
|
|
490
|
-
inset: '0',
|
|
491
|
-
pointerEvents: 'none',
|
|
492
|
-
});
|
|
493
|
-
const svg = createSvgElement('svg');
|
|
494
|
-
svg.setAttribute('viewBox', '0 0 100 100');
|
|
495
|
-
svg.setAttribute('preserveAspectRatio', 'none');
|
|
496
|
-
applyStyles(svg, {
|
|
497
|
-
width: '100%',
|
|
498
|
-
height: '100%',
|
|
499
|
-
display: 'block',
|
|
154
|
+
};
|
|
155
|
+
element.addEventListener('simface-captured', handleCaptured);
|
|
156
|
+
element.addEventListener('simface-cancelled', handleCancelled);
|
|
157
|
+
element.addEventListener('simface-error', handleError);
|
|
158
|
+
window.addEventListener('keydown', escapeHandler);
|
|
159
|
+
void element.startCapture().catch((error) => {
|
|
160
|
+
finalize(null, error instanceof Error ? error : new Error('Popup capture failed.'));
|
|
161
|
+
});
|
|
500
162
|
});
|
|
501
|
-
const mask = createSvgElement('path');
|
|
502
|
-
mask.setAttribute('d', CAPTURE_GUIDE_MASK_PATH);
|
|
503
|
-
mask.setAttribute('fill', 'rgba(51, 65, 85, 0.75)');
|
|
504
|
-
mask.setAttribute('fill-rule', 'evenodd');
|
|
505
|
-
const outline = createSvgElement('path');
|
|
506
|
-
outline.setAttribute('d', CAPTURE_GUIDE_PATH);
|
|
507
|
-
outline.setAttribute('fill', 'none');
|
|
508
|
-
outline.setAttribute('stroke', 'rgba(255, 255, 255, 0.92)');
|
|
509
|
-
outline.setAttribute('stroke-width', '2.8');
|
|
510
|
-
outline.setAttribute('stroke-linecap', 'round');
|
|
511
|
-
outline.setAttribute('stroke-linejoin', 'round');
|
|
512
|
-
const progress = createSvgElement('path');
|
|
513
|
-
progress.setAttribute('d', CAPTURE_GUIDE_PATH);
|
|
514
|
-
progress.setAttribute('fill', 'none');
|
|
515
|
-
progress.setAttribute('stroke', '#22c55e');
|
|
516
|
-
progress.setAttribute('stroke-width', '2.8');
|
|
517
|
-
progress.setAttribute('stroke-linecap', 'round');
|
|
518
|
-
progress.setAttribute('stroke-linejoin', 'round');
|
|
519
|
-
progress.setAttribute('pathLength', '100');
|
|
520
|
-
progress.setAttribute('stroke-dasharray', '100');
|
|
521
|
-
progress.setAttribute('stroke-dashoffset', '100');
|
|
522
|
-
progress.style.transition = 'stroke-dashoffset 0.14s linear, opacity 0.14s linear';
|
|
523
|
-
progress.style.opacity = '0';
|
|
524
|
-
svg.append(mask, outline, progress);
|
|
525
|
-
wrapper.append(svg);
|
|
526
|
-
return {
|
|
527
|
-
wrapper,
|
|
528
|
-
setProgress(value) {
|
|
529
|
-
const progressValue = Math.min(Math.max(value, 0), 1);
|
|
530
|
-
progress.setAttribute('stroke-dashoffset', `${100 - progressValue * 100}`);
|
|
531
|
-
progress.style.opacity = progressValue > 0 ? '1' : '0';
|
|
532
|
-
},
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
function createSvgElement(tagName) {
|
|
536
|
-
return document.createElementNS('http://www.w3.org/2000/svg', tagName);
|
|
537
|
-
}
|
|
538
|
-
function setFeedbackState(element, state) {
|
|
539
|
-
switch (state) {
|
|
540
|
-
case 'success':
|
|
541
|
-
applyStyles(element, {
|
|
542
|
-
background: '#dcfce7',
|
|
543
|
-
color: '#166534',
|
|
544
|
-
});
|
|
545
|
-
return;
|
|
546
|
-
case 'error':
|
|
547
|
-
applyStyles(element, {
|
|
548
|
-
background: '#fee2e2',
|
|
549
|
-
color: '#991b1b',
|
|
550
|
-
});
|
|
551
|
-
return;
|
|
552
|
-
case 'manual':
|
|
553
|
-
applyStyles(element, {
|
|
554
|
-
background: '#e0f2fe',
|
|
555
|
-
color: '#0f172a',
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
558
|
-
default:
|
|
559
|
-
applyStyles(element, {
|
|
560
|
-
background: '#e2e8f0',
|
|
561
|
-
color: '#0f172a',
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
163
|
}
|
|
565
164
|
function applyStyles(element, styles) {
|
|
566
165
|
Object.assign(element.style, styles);
|
|
567
166
|
}
|
|
568
|
-
|
|
569
|
-
if (error instanceof DOMException) {
|
|
570
|
-
switch (error.name) {
|
|
571
|
-
case 'NotAllowedError':
|
|
572
|
-
case 'SecurityError':
|
|
573
|
-
return 'Camera access was denied. Allow camera access and try again.';
|
|
574
|
-
case 'NotFoundError':
|
|
575
|
-
return 'No camera was found on this device.';
|
|
576
|
-
case 'NotReadableError':
|
|
577
|
-
return 'The camera is already in use by another application.';
|
|
578
|
-
default:
|
|
579
|
-
return error.message || 'Failed to access the camera.';
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
if (error instanceof Error) {
|
|
583
|
-
return error.message;
|
|
584
|
-
}
|
|
585
|
-
return 'Failed to access the camera.';
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* Loads a Blob as an HTMLImageElement for face detection analysis.
|
|
589
|
-
*/
|
|
590
|
-
export function blobToImage(blob) {
|
|
591
|
-
return new Promise((resolve, reject) => {
|
|
592
|
-
const url = URL.createObjectURL(blob);
|
|
593
|
-
const img = new Image();
|
|
594
|
-
img.onload = () => {
|
|
595
|
-
URL.revokeObjectURL(url);
|
|
596
|
-
resolve(img);
|
|
597
|
-
};
|
|
598
|
-
img.onerror = () => {
|
|
599
|
-
URL.revokeObjectURL(url);
|
|
600
|
-
reject(new Error('Failed to load captured image'));
|
|
601
|
-
};
|
|
602
|
-
img.src = url;
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* Creates a data URL from a Blob for display in an <img> tag.
|
|
607
|
-
*/
|
|
608
|
-
export function blobToDataURL(blob) {
|
|
609
|
-
return new Promise((resolve, reject) => {
|
|
610
|
-
const reader = new FileReader();
|
|
611
|
-
reader.onload = () => resolve(reader.result);
|
|
612
|
-
reader.onerror = () => reject(new Error('Failed to read image'));
|
|
613
|
-
reader.readAsDataURL(blob);
|
|
614
|
-
});
|
|
615
|
-
}
|
|
167
|
+
export { blobToImage, blobToDataURL };
|
|
616
168
|
//# sourceMappingURL=camera.js.map
|