@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
|
@@ -6,9 +6,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
};
|
|
7
7
|
import { LitElement, html, css } from 'lit';
|
|
8
8
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { assessFaceQuality } from '../services/face-detection.js';
|
|
10
|
+
import { CAPTURE_GUIDE_MASK_PATH, CAPTURE_GUIDE_PATH, } from '../shared/auto-capture.js';
|
|
11
|
+
import { buildCapturePlan, normalizeCaptureOptions, resolveCaptureCapabilities, } from '../shared/capture-flow.js';
|
|
12
|
+
import { CameraCaptureSessionController, } from '../shared/capture-session.js';
|
|
13
|
+
import { CameraAccessError, blobToImage, captureFromFileInput, openUserFacingCameraStream, } from '../shared/capture-runtime.js';
|
|
12
14
|
/**
|
|
13
15
|
* <simface-capture> — Web Component for capturing and quality-checking face images.
|
|
14
16
|
*
|
|
@@ -24,90 +26,63 @@ let SimFaceCapture = class SimFaceCapture extends LitElement {
|
|
|
24
26
|
this.embedded = false;
|
|
25
27
|
this.active = false;
|
|
26
28
|
this.confirmLabel = 'Use this capture';
|
|
29
|
+
this.capturePreference = 'auto-preferred';
|
|
30
|
+
this.allowMediaPickerFallback = true;
|
|
27
31
|
this.captureState = 'idle';
|
|
28
32
|
this.errorMessage = '';
|
|
29
33
|
this.feedbackMessage = 'Start a capture to see camera guidance here.';
|
|
30
34
|
this.feedbackTone = 'neutral';
|
|
31
35
|
this.previewUrl = '';
|
|
32
|
-
this.countdownProgress = 0;
|
|
33
36
|
this.qualityResult = null;
|
|
37
|
+
this.canTakePhoto = true;
|
|
38
|
+
this.captureMode = 'auto';
|
|
34
39
|
this.stream = null;
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.lastAnalysisTimestamp = 0;
|
|
40
|
+
this.sessionController = null;
|
|
41
|
+
this.currentCaptureStep = null;
|
|
38
42
|
this.capturedBlob = null;
|
|
39
|
-
this.
|
|
40
|
-
this.bestCaptureBlob = null;
|
|
41
|
-
this.bestCaptureScore = -1;
|
|
42
|
-
this.bestQualityResult = null;
|
|
43
|
+
this.pendingActiveSync = false;
|
|
43
44
|
}
|
|
44
45
|
disconnectedCallback() {
|
|
45
46
|
super.disconnectedCallback();
|
|
46
|
-
this.
|
|
47
|
+
this.stopSession();
|
|
47
48
|
}
|
|
48
49
|
updated(changedProperties) {
|
|
49
|
-
if (!
|
|
50
|
+
if (!changedProperties.has('active') || this.pendingActiveSync) {
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
this.pendingActiveSync = true;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
this.pendingActiveSync = false;
|
|
56
|
+
if (!this.isConnected) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (this.active) {
|
|
60
|
+
if (this.captureState === 'idle') {
|
|
61
|
+
void this.beginCapture();
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.endCapture();
|
|
66
|
+
});
|
|
58
67
|
}
|
|
59
68
|
render() {
|
|
60
|
-
if (this.embedded) {
|
|
61
|
-
return html `
|
|
62
|
-
<div class="container embedded-shell">
|
|
63
|
-
${this.renderEmbeddedState()}
|
|
64
|
-
</div>
|
|
65
|
-
`;
|
|
66
|
-
}
|
|
67
69
|
return html `
|
|
68
|
-
<div class="container">
|
|
69
|
-
${this.
|
|
70
|
+
<div class="container capture-shell">
|
|
71
|
+
${this.renderCaptureState()}
|
|
70
72
|
</div>
|
|
71
73
|
`;
|
|
72
74
|
}
|
|
73
75
|
async startCapture() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
await this.handlePopupCapture();
|
|
80
|
-
}
|
|
81
|
-
renderPopupState() {
|
|
82
|
-
switch (this.captureState) {
|
|
83
|
-
case 'idle':
|
|
84
|
-
return html `
|
|
85
|
-
<p>${this.label}</p>
|
|
86
|
-
<button class="btn btn-primary" @click=${this.handlePopupCapture}>
|
|
87
|
-
Open Camera
|
|
88
|
-
</button>
|
|
89
|
-
`;
|
|
90
|
-
case 'starting':
|
|
91
|
-
return html `
|
|
92
|
-
<p>Opening camera...</p>
|
|
93
|
-
<div class="spinner"></div>
|
|
94
|
-
`;
|
|
95
|
-
case 'error':
|
|
96
|
-
return html `
|
|
97
|
-
<div class="quality-msg quality-bad">${this.errorMessage}</div>
|
|
98
|
-
<button class="btn btn-primary" @click=${this.handlePopupRetake}>Try again</button>
|
|
99
|
-
<button class="btn btn-secondary" @click=${this.handlePopupCancel}>Cancel</button>
|
|
100
|
-
`;
|
|
101
|
-
default:
|
|
102
|
-
return html ``;
|
|
103
|
-
}
|
|
76
|
+
this.active = true;
|
|
77
|
+
await this.updateComplete;
|
|
78
|
+
await this.beginCapture();
|
|
104
79
|
}
|
|
105
|
-
|
|
80
|
+
renderCaptureState() {
|
|
106
81
|
return html `
|
|
107
|
-
<p class="
|
|
82
|
+
<p class="capture-copy">${this.label}</p>
|
|
108
83
|
|
|
109
84
|
${this.captureState === 'idle'
|
|
110
|
-
? html `<p class="
|
|
85
|
+
? html `<p class="capture-copy">Waiting for the host page to start capture.</p>`
|
|
111
86
|
: html `
|
|
112
87
|
<div class="stage">
|
|
113
88
|
<video
|
|
@@ -124,7 +99,6 @@ let SimFaceCapture = class SimFaceCapture extends LitElement {
|
|
|
124
99
|
/>
|
|
125
100
|
<div
|
|
126
101
|
class="guide-overlay ${this.captureState === 'live' || this.captureState === 'starting' ? '' : 'hidden'}"
|
|
127
|
-
style=${`--capture-progress:${this.countdownProgress};`}
|
|
128
102
|
>
|
|
129
103
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
130
104
|
<path class="guide-mask" d=${CAPTURE_GUIDE_MASK_PATH}></path>
|
|
@@ -142,255 +116,197 @@ let SimFaceCapture = class SimFaceCapture extends LitElement {
|
|
|
142
116
|
<div class="btn-row">
|
|
143
117
|
${this.captureState === 'live'
|
|
144
118
|
? html `
|
|
145
|
-
|
|
146
|
-
|
|
119
|
+
${this.captureMode === 'manual'
|
|
120
|
+
? html `<button class="btn btn-secondary" data-simface-action="capture" ?disabled=${!this.canTakePhoto} @click=${this.handleManualCapture}>Take photo</button>`
|
|
121
|
+
: ''}
|
|
122
|
+
<button class="btn btn-ghost" data-simface-action="cancel" @click=${this.handleCancel}>Cancel</button>
|
|
147
123
|
`
|
|
148
124
|
: ''}
|
|
149
125
|
${this.captureState === 'preview'
|
|
150
126
|
? html `
|
|
151
|
-
<button class="btn btn-secondary" @click=${this.
|
|
127
|
+
<button class="btn btn-secondary" data-simface-action="retake" @click=${this.handleRetake}>Retake</button>
|
|
152
128
|
${this.qualityResult?.passesQualityChecks === false
|
|
153
129
|
? ''
|
|
154
|
-
: html `<button class="btn btn-primary" @click=${this.
|
|
155
|
-
<button class="btn btn-ghost" @click=${this.
|
|
130
|
+
: html `<button class="btn btn-primary" data-simface-action="confirm" @click=${this.handleConfirm}>${this.confirmLabel}</button>`}
|
|
131
|
+
<button class="btn btn-ghost" data-simface-action="cancel" @click=${this.handleCancel}>Cancel</button>
|
|
156
132
|
`
|
|
157
133
|
: ''}
|
|
158
134
|
${this.captureState === 'error'
|
|
159
135
|
? html `
|
|
160
|
-
<button class="btn btn-primary" @click=${this.
|
|
161
|
-
<button class="btn btn-ghost" @click=${this.
|
|
136
|
+
<button class="btn btn-primary" data-simface-action="retry" @click=${this.beginCapture}>Try again</button>
|
|
137
|
+
<button class="btn btn-ghost" data-simface-action="cancel" @click=${this.handleCancel}>Cancel</button>
|
|
162
138
|
`
|
|
163
139
|
: ''}
|
|
164
140
|
</div>
|
|
165
141
|
`;
|
|
166
142
|
}
|
|
167
|
-
async
|
|
168
|
-
this.captureState = 'starting';
|
|
169
|
-
try {
|
|
170
|
-
const blob = await captureFromCamera();
|
|
171
|
-
if (!blob) {
|
|
172
|
-
this.dispatchCancelled();
|
|
173
|
-
this.captureState = 'idle';
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
this.dispatchCaptured(blob);
|
|
177
|
-
this.resetPopupState();
|
|
178
|
-
}
|
|
179
|
-
catch (err) {
|
|
180
|
-
this.errorMessage = err instanceof Error ? err.message : 'Capture failed';
|
|
181
|
-
this.captureState = 'error';
|
|
182
|
-
this.dispatchError(this.errorMessage);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
handlePopupRetake() {
|
|
186
|
-
this.resetPopupState();
|
|
187
|
-
void this.handlePopupCapture();
|
|
188
|
-
}
|
|
189
|
-
handlePopupCancel() {
|
|
190
|
-
this.dispatchCancelled();
|
|
191
|
-
this.resetPopupState();
|
|
192
|
-
}
|
|
193
|
-
async startEmbeddedCapture() {
|
|
143
|
+
async beginCapture() {
|
|
194
144
|
if (!this.active || this.captureState === 'starting' || this.captureState === 'live') {
|
|
195
145
|
return;
|
|
196
146
|
}
|
|
197
|
-
this.
|
|
198
|
-
this.
|
|
147
|
+
this.stopSession();
|
|
148
|
+
this.resetState();
|
|
199
149
|
this.captureState = 'starting';
|
|
200
150
|
this.feedbackMessage = 'Requesting camera access...';
|
|
201
151
|
this.feedbackTone = 'neutral';
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
152
|
+
const options = normalizeCaptureOptions({
|
|
153
|
+
presentation: 'embedded',
|
|
154
|
+
capturePreference: this.capturePreference,
|
|
155
|
+
allowMediaPickerFallback: this.allowMediaPickerFallback,
|
|
156
|
+
label: this.label,
|
|
157
|
+
confirmLabel: this.confirmLabel,
|
|
158
|
+
});
|
|
159
|
+
const capabilities = await resolveCaptureCapabilities({
|
|
160
|
+
capturePreference: options.capturePreference,
|
|
161
|
+
});
|
|
162
|
+
const plan = buildCapturePlan(options, capabilities);
|
|
163
|
+
const cameraStep = plan.steps.find((step) => step === 'auto-camera' || step === 'manual-camera') ?? null;
|
|
164
|
+
const hasMediaPickerFallback = plan.steps.includes('media-picker');
|
|
165
|
+
if (!cameraStep) {
|
|
166
|
+
await this.startMediaPicker();
|
|
205
167
|
return;
|
|
206
168
|
}
|
|
207
169
|
try {
|
|
208
|
-
this.stream = await
|
|
209
|
-
video: { facingMode: { ideal: 'user' } },
|
|
210
|
-
audio: false,
|
|
211
|
-
});
|
|
212
|
-
this.captureState = 'live';
|
|
213
|
-
this.feedbackMessage = 'Center your face in the oval. We will capture automatically when framing looks good.';
|
|
214
|
-
await this.updateComplete;
|
|
215
|
-
const video = this.embeddedVideoElement;
|
|
216
|
-
if (!video) {
|
|
217
|
-
throw new Error('Inline camera preview could not be created.');
|
|
218
|
-
}
|
|
219
|
-
video.srcObject = this.stream;
|
|
220
|
-
await this.waitForVideoReady(video);
|
|
221
|
-
this.scheduleEmbeddedAnalysis();
|
|
170
|
+
this.stream = await openUserFacingCameraStream();
|
|
222
171
|
}
|
|
223
172
|
catch (error) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
173
|
+
if (error instanceof CameraAccessError && hasMediaPickerFallback) {
|
|
174
|
+
await this.startMediaPicker();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.handleCaptureError(error);
|
|
229
178
|
return;
|
|
230
179
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.
|
|
234
|
-
this.feedbackTone = 'neutral';
|
|
180
|
+
await this.updateComplete;
|
|
181
|
+
if (!this.active) {
|
|
182
|
+
this.stopSession();
|
|
235
183
|
return;
|
|
236
184
|
}
|
|
237
|
-
this.animationFrameId = window.requestAnimationFrame(async (timestamp) => {
|
|
238
|
-
if (this.captureState !== 'live' ||
|
|
239
|
-
this.analysisInFlight ||
|
|
240
|
-
timestamp - this.lastAnalysisTimestamp < AUTO_CAPTURE_ANALYSIS_INTERVAL_MS) {
|
|
241
|
-
this.scheduleEmbeddedAnalysis();
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const video = this.embeddedVideoElement;
|
|
245
|
-
if (!video) {
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
this.lastAnalysisTimestamp = timestamp;
|
|
249
|
-
this.analysisInFlight = true;
|
|
250
|
-
try {
|
|
251
|
-
const qualityResult = await assessFaceQualityForVideo(video, timestamp);
|
|
252
|
-
this.qualityResult = qualityResult;
|
|
253
|
-
if (qualityResult.passesQualityChecks) {
|
|
254
|
-
if (this.countdownStartedAt === null) {
|
|
255
|
-
this.countdownStartedAt = timestamp;
|
|
256
|
-
this.countdownProgress = 0;
|
|
257
|
-
this.feedbackMessage = 'Great framing detected. Hold still while we pick the best frame.';
|
|
258
|
-
this.feedbackTone = 'success';
|
|
259
|
-
}
|
|
260
|
-
await this.considerBestFrame(video, qualityResult);
|
|
261
|
-
}
|
|
262
|
-
if (this.countdownStartedAt !== null) {
|
|
263
|
-
this.countdownProgress = Math.min((timestamp - this.countdownStartedAt) / AUTO_CAPTURE_COUNTDOWN_MS, 1);
|
|
264
|
-
this.feedbackMessage = autoCaptureCountdownMessage(timestamp, this.countdownStartedAt, qualityResult);
|
|
265
|
-
this.feedbackTone = qualityResult.passesQualityChecks ? 'success' : 'neutral';
|
|
266
|
-
if (this.countdownProgress >= 1) {
|
|
267
|
-
this.finishCountdownCapture();
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
this.feedbackMessage = qualityResult.message;
|
|
273
|
-
this.feedbackTone = 'neutral';
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
this.feedbackMessage = 'Automatic analysis is unavailable. Use Take photo now.';
|
|
278
|
-
this.feedbackTone = 'neutral';
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
finally {
|
|
282
|
-
this.analysisInFlight = false;
|
|
283
|
-
}
|
|
284
|
-
this.scheduleEmbeddedAnalysis();
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
async captureEmbeddedFrame() {
|
|
288
185
|
const video = this.embeddedVideoElement;
|
|
289
|
-
if (!video || this.
|
|
186
|
+
if (!video || !this.stream) {
|
|
187
|
+
this.handleCaptureError(new Error('Inline camera preview could not be created.'));
|
|
290
188
|
return;
|
|
291
189
|
}
|
|
190
|
+
video.srcObject = this.stream;
|
|
191
|
+
this.currentCaptureStep = cameraStep;
|
|
192
|
+
this.sessionController = new CameraCaptureSessionController({
|
|
193
|
+
videoElement: video,
|
|
194
|
+
initialMode: cameraStep === 'auto-camera' ? 'auto' : 'manual',
|
|
195
|
+
copy: {
|
|
196
|
+
autoReadyMessage: 'Center your face in the oval. We will capture automatically when framing looks good.',
|
|
197
|
+
manualReadyMessage: 'When you are ready, press Take photo.',
|
|
198
|
+
autoUnavailableMessage: 'Automatic capture is unavailable. Press Take photo instead.',
|
|
199
|
+
retakeReadyMessage: 'When you are ready, press Take photo.',
|
|
200
|
+
},
|
|
201
|
+
onStateChange: (state) => this.applySessionState(state),
|
|
202
|
+
});
|
|
292
203
|
try {
|
|
293
|
-
|
|
294
|
-
const qualityResult = await this.assessCapturedBlob(blob);
|
|
295
|
-
this.capturedBlob = blob;
|
|
296
|
-
this.qualityResult = qualityResult;
|
|
297
|
-
this.captureState = 'preview';
|
|
298
|
-
this.feedbackMessage = qualityResult?.message ?? 'Review this capture before continuing.';
|
|
299
|
-
this.feedbackTone = qualityResult
|
|
300
|
-
? qualityResult.passesQualityChecks
|
|
301
|
-
? 'success'
|
|
302
|
-
: 'error'
|
|
303
|
-
: 'neutral';
|
|
304
|
-
if (this.previewUrl) {
|
|
305
|
-
URL.revokeObjectURL(this.previewUrl);
|
|
306
|
-
}
|
|
307
|
-
this.previewUrl = URL.createObjectURL(blob);
|
|
308
|
-
this.countdownProgress = 0;
|
|
204
|
+
await this.sessionController.start();
|
|
309
205
|
}
|
|
310
206
|
catch (error) {
|
|
311
|
-
this.
|
|
207
|
+
this.handleCaptureError(error);
|
|
312
208
|
}
|
|
313
209
|
}
|
|
314
|
-
async
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
this.
|
|
320
|
-
this.bestCaptureScore = qualityResult.captureScore;
|
|
321
|
-
this.bestQualityResult = qualityResult;
|
|
322
|
-
}
|
|
323
|
-
finishCountdownCapture() {
|
|
324
|
-
if (!this.bestCaptureBlob) {
|
|
325
|
-
void this.captureEmbeddedFrame();
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
this.capturedBlob = this.bestCaptureBlob;
|
|
329
|
-
this.qualityResult = this.bestQualityResult;
|
|
330
|
-
this.captureState = 'preview';
|
|
331
|
-
this.feedbackMessage = autoCaptureCompleteMessage(this.bestQualityResult);
|
|
332
|
-
this.feedbackTone = 'success';
|
|
333
|
-
if (this.previewUrl) {
|
|
334
|
-
URL.revokeObjectURL(this.previewUrl);
|
|
335
|
-
}
|
|
336
|
-
this.previewUrl = URL.createObjectURL(this.bestCaptureBlob);
|
|
337
|
-
this.countdownProgress = 1;
|
|
338
|
-
}
|
|
339
|
-
async assessCapturedBlob(blob) {
|
|
210
|
+
async startMediaPicker() {
|
|
211
|
+
this.stopSession();
|
|
212
|
+
this.currentCaptureStep = 'media-picker';
|
|
213
|
+
this.captureState = 'starting';
|
|
214
|
+
this.feedbackMessage = 'Opening media picker...';
|
|
215
|
+
this.feedbackTone = 'neutral';
|
|
340
216
|
try {
|
|
341
|
-
const
|
|
342
|
-
|
|
217
|
+
const blob = await captureFromFileInput();
|
|
218
|
+
if (!blob) {
|
|
219
|
+
this.handleCancel();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await this.showPickedPreview(blob);
|
|
343
223
|
}
|
|
344
|
-
catch {
|
|
345
|
-
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.handleCaptureError(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
applySessionState(state) {
|
|
229
|
+
this.captureState = state.phase;
|
|
230
|
+
this.feedbackMessage = state.feedbackMessage;
|
|
231
|
+
this.feedbackTone = state.feedbackTone;
|
|
232
|
+
this.syncProgress(state.countdownProgress);
|
|
233
|
+
this.qualityResult = state.qualityResult;
|
|
234
|
+
this.errorMessage = state.phase === 'error' ? state.errorMessage : '';
|
|
235
|
+
this.canTakePhoto = state.canTakePhoto;
|
|
236
|
+
this.captureMode = state.mode;
|
|
237
|
+
if (state.phase === 'preview') {
|
|
238
|
+
this.capturedBlob = state.previewBlob;
|
|
239
|
+
this.setPreviewBlob(state.previewBlob);
|
|
240
|
+
return;
|
|
346
241
|
}
|
|
242
|
+
this.capturedBlob = null;
|
|
243
|
+
this.clearPreviewUrl();
|
|
347
244
|
}
|
|
348
|
-
|
|
349
|
-
|
|
245
|
+
async showPickedPreview(blob) {
|
|
246
|
+
const qualityResult = await this.assessPickedBlob(blob);
|
|
247
|
+
this.capturedBlob = blob;
|
|
248
|
+
this.qualityResult = qualityResult;
|
|
249
|
+
this.captureState = 'preview';
|
|
250
|
+
this.feedbackMessage = qualityResult?.message ?? 'Review this capture before continuing.';
|
|
251
|
+
this.feedbackTone = qualityResult
|
|
252
|
+
? qualityResult.passesQualityChecks
|
|
253
|
+
? 'success'
|
|
254
|
+
: 'error'
|
|
255
|
+
: 'neutral';
|
|
256
|
+
this.syncProgress(qualityResult ? 1 : 0);
|
|
257
|
+
this.setPreviewBlob(blob);
|
|
258
|
+
}
|
|
259
|
+
handleManualCapture() {
|
|
260
|
+
void this.sessionController?.takePhotoNow().catch((error) => {
|
|
261
|
+
this.handleCaptureError(error);
|
|
262
|
+
});
|
|
350
263
|
}
|
|
351
|
-
|
|
264
|
+
handleRetake() {
|
|
352
265
|
this.capturedBlob = null;
|
|
353
266
|
this.qualityResult = null;
|
|
354
|
-
this.
|
|
355
|
-
if (this.
|
|
356
|
-
|
|
357
|
-
|
|
267
|
+
this.clearPreviewUrl();
|
|
268
|
+
if (this.currentCaptureStep === 'media-picker') {
|
|
269
|
+
if (this.active) {
|
|
270
|
+
void this.beginCapture();
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
358
273
|
}
|
|
359
|
-
this.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.resumeEmbeddedVideo();
|
|
363
|
-
this.scheduleEmbeddedAnalysis();
|
|
274
|
+
void this.sessionController?.retake().catch((error) => {
|
|
275
|
+
this.handleCaptureError(error);
|
|
276
|
+
});
|
|
364
277
|
}
|
|
365
|
-
|
|
278
|
+
handleConfirm() {
|
|
366
279
|
if (!this.capturedBlob) {
|
|
367
280
|
return;
|
|
368
281
|
}
|
|
369
282
|
const blob = this.capturedBlob;
|
|
370
283
|
this.active = false;
|
|
371
|
-
this.
|
|
372
|
-
this.
|
|
284
|
+
this.stopSession();
|
|
285
|
+
this.resetState();
|
|
373
286
|
this.dispatchCaptured(blob);
|
|
374
287
|
}
|
|
375
|
-
|
|
288
|
+
handleCancel() {
|
|
376
289
|
this.active = false;
|
|
377
|
-
this.
|
|
378
|
-
this.
|
|
290
|
+
this.stopSession();
|
|
291
|
+
this.resetState();
|
|
379
292
|
this.dispatchCancelled();
|
|
380
293
|
}
|
|
381
|
-
|
|
382
|
-
this.
|
|
294
|
+
handleCaptureError(error) {
|
|
295
|
+
this.stopSession();
|
|
383
296
|
this.errorMessage = error instanceof Error ? error.message : 'Capture failed';
|
|
384
297
|
this.captureState = 'error';
|
|
385
298
|
this.feedbackMessage = this.errorMessage;
|
|
386
299
|
this.feedbackTone = 'error';
|
|
387
300
|
this.dispatchError(this.errorMessage);
|
|
388
301
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
302
|
+
endCapture() {
|
|
303
|
+
this.stopSession();
|
|
304
|
+
this.resetState();
|
|
305
|
+
}
|
|
306
|
+
stopSession() {
|
|
307
|
+
this.sessionController?.stop();
|
|
308
|
+
this.sessionController = null;
|
|
309
|
+
this.currentCaptureStep = null;
|
|
394
310
|
if (this.stream) {
|
|
395
311
|
this.stream.getTracks().forEach((track) => track.stop());
|
|
396
312
|
this.stream = null;
|
|
@@ -399,26 +315,31 @@ let SimFaceCapture = class SimFaceCapture extends LitElement {
|
|
|
399
315
|
if (video) {
|
|
400
316
|
video.srcObject = null;
|
|
401
317
|
}
|
|
402
|
-
this.
|
|
403
|
-
this.lastAnalysisTimestamp = 0;
|
|
404
|
-
this.resetCountdown();
|
|
318
|
+
this.syncProgress(0);
|
|
405
319
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
URL.revokeObjectURL(this.previewUrl);
|
|
409
|
-
this.previewUrl = '';
|
|
410
|
-
}
|
|
320
|
+
resetState() {
|
|
321
|
+
this.clearPreviewUrl();
|
|
411
322
|
this.captureState = 'idle';
|
|
412
323
|
this.errorMessage = '';
|
|
413
324
|
this.feedbackMessage = 'Start a capture to see camera guidance here.';
|
|
414
325
|
this.feedbackTone = 'neutral';
|
|
415
|
-
this.
|
|
326
|
+
this.syncProgress(0);
|
|
416
327
|
this.qualityResult = null;
|
|
417
328
|
this.capturedBlob = null;
|
|
329
|
+
this.captureMode = 'auto';
|
|
418
330
|
}
|
|
419
|
-
|
|
420
|
-
this.
|
|
421
|
-
|
|
331
|
+
syncProgress(progress) {
|
|
332
|
+
this.style.setProperty('--capture-progress', String(progress));
|
|
333
|
+
}
|
|
334
|
+
setPreviewBlob(blob) {
|
|
335
|
+
this.clearPreviewUrl();
|
|
336
|
+
this.previewUrl = URL.createObjectURL(blob);
|
|
337
|
+
}
|
|
338
|
+
clearPreviewUrl() {
|
|
339
|
+
if (this.previewUrl) {
|
|
340
|
+
URL.revokeObjectURL(this.previewUrl);
|
|
341
|
+
this.previewUrl = '';
|
|
342
|
+
}
|
|
422
343
|
}
|
|
423
344
|
dispatchCaptured(blob) {
|
|
424
345
|
this.dispatchEvent(new CustomEvent('simface-captured', {
|
|
@@ -447,66 +368,19 @@ let SimFaceCapture = class SimFaceCapture extends LitElement {
|
|
|
447
368
|
if (this.feedbackTone === 'error') {
|
|
448
369
|
return 'quality-bad';
|
|
449
370
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
waitForVideoReady(video) {
|
|
453
|
-
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
454
|
-
return video.play().then(() => undefined);
|
|
371
|
+
if (this.feedbackTone === 'manual') {
|
|
372
|
+
return 'quality-manual';
|
|
455
373
|
}
|
|
456
|
-
return
|
|
457
|
-
const handleReady = () => {
|
|
458
|
-
cleanup();
|
|
459
|
-
video.play().then(() => resolve()).catch(reject);
|
|
460
|
-
};
|
|
461
|
-
const handleError = () => {
|
|
462
|
-
cleanup();
|
|
463
|
-
reject(new Error('Failed to start the inline camera preview.'));
|
|
464
|
-
};
|
|
465
|
-
const cleanup = () => {
|
|
466
|
-
video.removeEventListener('loadedmetadata', handleReady);
|
|
467
|
-
video.removeEventListener('error', handleError);
|
|
468
|
-
};
|
|
469
|
-
video.addEventListener('loadedmetadata', handleReady, { once: true });
|
|
470
|
-
video.addEventListener('error', handleError, { once: true });
|
|
471
|
-
});
|
|
374
|
+
return 'quality-neutral';
|
|
472
375
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const canvas = document.createElement('canvas');
|
|
478
|
-
canvas.width = video.videoWidth;
|
|
479
|
-
canvas.height = video.videoHeight;
|
|
480
|
-
const context = canvas.getContext('2d');
|
|
481
|
-
if (!context) {
|
|
482
|
-
return Promise.reject(new Error('Failed to initialize camera capture.'));
|
|
376
|
+
async assessPickedBlob(blob) {
|
|
377
|
+
try {
|
|
378
|
+
const image = await blobToImage(blob);
|
|
379
|
+
return await assessFaceQuality(image);
|
|
483
380
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
canvas.toBlob((blob) => {
|
|
487
|
-
if (!blob) {
|
|
488
|
-
reject(new Error('Failed to capture an image.'));
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
resolve(blob);
|
|
492
|
-
}, 'image/jpeg', 0.92);
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
resumeEmbeddedVideo() {
|
|
496
|
-
const video = this.embeddedVideoElement;
|
|
497
|
-
if (!video) {
|
|
498
|
-
return;
|
|
381
|
+
catch {
|
|
382
|
+
return null;
|
|
499
383
|
}
|
|
500
|
-
void video.play().catch(() => {
|
|
501
|
-
// Ignore replay failures here; the initial preview startup path already errors loudly.
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
resetCountdown() {
|
|
505
|
-
this.countdownStartedAt = null;
|
|
506
|
-
this.countdownProgress = 0;
|
|
507
|
-
this.bestCaptureBlob = null;
|
|
508
|
-
this.bestCaptureScore = -1;
|
|
509
|
-
this.bestQualityResult = null;
|
|
510
384
|
}
|
|
511
385
|
};
|
|
512
386
|
SimFaceCapture.styles = css `
|
|
@@ -516,6 +390,7 @@ SimFaceCapture.styles = css `
|
|
|
516
390
|
max-width: 400px;
|
|
517
391
|
margin: 0 auto;
|
|
518
392
|
text-align: center;
|
|
393
|
+
color-scheme: light;
|
|
519
394
|
}
|
|
520
395
|
|
|
521
396
|
:host([embedded]) {
|
|
@@ -531,13 +406,13 @@ SimFaceCapture.styles = css `
|
|
|
531
406
|
background: #fafafa;
|
|
532
407
|
}
|
|
533
408
|
|
|
534
|
-
.
|
|
409
|
+
.capture-shell {
|
|
535
410
|
display: flex;
|
|
536
411
|
flex-direction: column;
|
|
537
412
|
gap: 16px;
|
|
538
413
|
}
|
|
539
414
|
|
|
540
|
-
.
|
|
415
|
+
.capture-copy {
|
|
541
416
|
margin: 0;
|
|
542
417
|
color: #334155;
|
|
543
418
|
}
|
|
@@ -560,6 +435,7 @@ SimFaceCapture.styles = css `
|
|
|
560
435
|
width: 100%;
|
|
561
436
|
height: 100%;
|
|
562
437
|
object-fit: cover;
|
|
438
|
+
transform: scaleX(-1);
|
|
563
439
|
}
|
|
564
440
|
|
|
565
441
|
.preview-img {
|
|
@@ -611,12 +487,6 @@ SimFaceCapture.styles = css `
|
|
|
611
487
|
gap: 12px;
|
|
612
488
|
}
|
|
613
489
|
|
|
614
|
-
.preview-img-inline {
|
|
615
|
-
max-width: 100%;
|
|
616
|
-
border-radius: 8px;
|
|
617
|
-
margin: 12px 0;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
490
|
.btn {
|
|
621
491
|
display: inline-flex;
|
|
622
492
|
align-items: center;
|
|
@@ -682,6 +552,11 @@ SimFaceCapture.styles = css `
|
|
|
682
552
|
color: #0f172a;
|
|
683
553
|
}
|
|
684
554
|
|
|
555
|
+
.quality-manual {
|
|
556
|
+
background: #e0f2fe;
|
|
557
|
+
color: #0f172a;
|
|
558
|
+
}
|
|
559
|
+
|
|
685
560
|
.spinner {
|
|
686
561
|
display: inline-block;
|
|
687
562
|
width: 24px;
|
|
@@ -715,6 +590,12 @@ __decorate([
|
|
|
715
590
|
__decorate([
|
|
716
591
|
property({ type: String, attribute: 'confirm-label' })
|
|
717
592
|
], SimFaceCapture.prototype, "confirmLabel", void 0);
|
|
593
|
+
__decorate([
|
|
594
|
+
property({ type: String, attribute: 'capture-preference' })
|
|
595
|
+
], SimFaceCapture.prototype, "capturePreference", void 0);
|
|
596
|
+
__decorate([
|
|
597
|
+
property({ type: Boolean, attribute: 'allow-media-picker-fallback' })
|
|
598
|
+
], SimFaceCapture.prototype, "allowMediaPickerFallback", void 0);
|
|
718
599
|
__decorate([
|
|
719
600
|
state()
|
|
720
601
|
], SimFaceCapture.prototype, "captureState", void 0);
|
|
@@ -732,10 +613,13 @@ __decorate([
|
|
|
732
613
|
], SimFaceCapture.prototype, "previewUrl", void 0);
|
|
733
614
|
__decorate([
|
|
734
615
|
state()
|
|
735
|
-
], SimFaceCapture.prototype, "
|
|
616
|
+
], SimFaceCapture.prototype, "qualityResult", void 0);
|
|
736
617
|
__decorate([
|
|
737
618
|
state()
|
|
738
|
-
], SimFaceCapture.prototype, "
|
|
619
|
+
], SimFaceCapture.prototype, "canTakePhoto", void 0);
|
|
620
|
+
__decorate([
|
|
621
|
+
state()
|
|
622
|
+
], SimFaceCapture.prototype, "captureMode", void 0);
|
|
739
623
|
__decorate([
|
|
740
624
|
query('#embedded-video')
|
|
741
625
|
], SimFaceCapture.prototype, "embeddedVideoElement", void 0);
|