@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.
@@ -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 { blobToImage, captureFromCamera } from '../services/camera.js';
10
- import { assessFaceQuality, assessFaceQualityForVideo } from '../services/face-detection.js';
11
- import { AUTO_CAPTURE_ANALYSIS_INTERVAL_MS, AUTO_CAPTURE_COUNTDOWN_MS, CAPTURE_GUIDE_MASK_PATH, CAPTURE_GUIDE_PATH, autoCaptureCompleteMessage, autoCaptureCountdownMessage, } from '../shared/auto-capture.js';
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.animationFrameId = null;
36
- this.analysisInFlight = false;
37
- this.lastAnalysisTimestamp = 0;
40
+ this.sessionController = null;
41
+ this.currentCaptureStep = null;
38
42
  this.capturedBlob = null;
39
- this.countdownStartedAt = null;
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.stopEmbeddedSession();
47
+ this.stopSession();
47
48
  }
48
49
  updated(changedProperties) {
49
- if (!this.embedded || !changedProperties.has('active')) {
50
+ if (!changedProperties.has('active') || this.pendingActiveSync) {
50
51
  return;
51
52
  }
52
- if (this.active) {
53
- void this.startEmbeddedCapture();
54
- return;
55
- }
56
- this.stopEmbeddedSession();
57
- this.resetEmbeddedState();
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.renderPopupState()}
70
+ <div class="container capture-shell">
71
+ ${this.renderCaptureState()}
70
72
  </div>
71
73
  `;
72
74
  }
73
75
  async startCapture() {
74
- if (this.embedded) {
75
- this.active = true;
76
- await this.updateComplete;
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
- renderEmbeddedState() {
80
+ renderCaptureState() {
106
81
  return html `
107
- <p class="embedded-copy">${this.label}</p>
82
+ <p class="capture-copy">${this.label}</p>
108
83
 
109
84
  ${this.captureState === 'idle'
110
- ? html `<p class="embedded-copy">Waiting for the host page to start capture.</p>`
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
- <button class="btn btn-secondary" @click=${this.handleEmbeddedManualCapture}>Take photo now</button>
146
- <button class="btn btn-ghost" @click=${this.handleEmbeddedCancel}>Cancel</button>
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.handleEmbeddedRetake}>Retake</button>
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.handleEmbeddedConfirm}>${this.confirmLabel}</button>`}
155
- <button class="btn btn-ghost" @click=${this.handleEmbeddedCancel}>Cancel</button>
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.startEmbeddedCapture}>Try again</button>
161
- <button class="btn btn-ghost" @click=${this.handleEmbeddedCancel}>Cancel</button>
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 handlePopupCapture() {
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.stopEmbeddedSession();
198
- this.resetEmbeddedState();
147
+ this.stopSession();
148
+ this.resetState();
199
149
  this.captureState = 'starting';
200
150
  this.feedbackMessage = 'Requesting camera access...';
201
151
  this.feedbackTone = 'neutral';
202
- await this.updateComplete;
203
- if (!navigator.mediaDevices?.getUserMedia) {
204
- this.handleEmbeddedError(new Error('This browser does not support inline camera capture.'));
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 navigator.mediaDevices.getUserMedia({
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
- this.handleEmbeddedError(error);
225
- }
226
- }
227
- scheduleEmbeddedAnalysis() {
228
- if (this.captureState !== 'live' || !this.stream) {
173
+ if (error instanceof CameraAccessError && hasMediaPickerFallback) {
174
+ await this.startMediaPicker();
175
+ return;
176
+ }
177
+ this.handleCaptureError(error);
229
178
  return;
230
179
  }
231
- if (typeof window.requestAnimationFrame !== 'function' ||
232
- typeof window.cancelAnimationFrame !== 'function') {
233
- this.feedbackMessage = 'Automatic analysis is unavailable. Use Take photo now.';
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.captureState !== 'live') {
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
- const blob = await this.captureVideoFrame(video);
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.handleEmbeddedError(error);
207
+ this.handleCaptureError(error);
312
208
  }
313
209
  }
314
- async considerBestFrame(video, qualityResult) {
315
- if (qualityResult.captureScore <= this.bestCaptureScore) {
316
- return;
317
- }
318
- const blob = await this.captureVideoFrame(video);
319
- this.bestCaptureBlob = blob;
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 image = await blobToImage(blob);
342
- return await assessFaceQuality(image);
217
+ const blob = await captureFromFileInput();
218
+ if (!blob) {
219
+ this.handleCancel();
220
+ return;
221
+ }
222
+ await this.showPickedPreview(blob);
343
223
  }
344
- catch {
345
- return null;
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
- handleEmbeddedManualCapture() {
349
- void this.captureEmbeddedFrame();
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
- handleEmbeddedRetake() {
264
+ handleRetake() {
352
265
  this.capturedBlob = null;
353
266
  this.qualityResult = null;
354
- this.captureState = 'live';
355
- if (this.previewUrl) {
356
- URL.revokeObjectURL(this.previewUrl);
357
- this.previewUrl = '';
267
+ this.clearPreviewUrl();
268
+ if (this.currentCaptureStep === 'media-picker') {
269
+ if (this.active) {
270
+ void this.beginCapture();
271
+ }
272
+ return;
358
273
  }
359
- this.feedbackMessage = 'Ready to capture again.';
360
- this.feedbackTone = 'neutral';
361
- this.resetCountdown();
362
- this.resumeEmbeddedVideo();
363
- this.scheduleEmbeddedAnalysis();
274
+ void this.sessionController?.retake().catch((error) => {
275
+ this.handleCaptureError(error);
276
+ });
364
277
  }
365
- handleEmbeddedConfirm() {
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.stopEmbeddedSession();
372
- this.resetEmbeddedState();
284
+ this.stopSession();
285
+ this.resetState();
373
286
  this.dispatchCaptured(blob);
374
287
  }
375
- handleEmbeddedCancel() {
288
+ handleCancel() {
376
289
  this.active = false;
377
- this.stopEmbeddedSession();
378
- this.resetEmbeddedState();
290
+ this.stopSession();
291
+ this.resetState();
379
292
  this.dispatchCancelled();
380
293
  }
381
- handleEmbeddedError(error) {
382
- this.stopEmbeddedSession();
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
- stopEmbeddedSession() {
390
- if (this.animationFrameId !== null) {
391
- window.cancelAnimationFrame(this.animationFrameId);
392
- this.animationFrameId = null;
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.analysisInFlight = false;
403
- this.lastAnalysisTimestamp = 0;
404
- this.resetCountdown();
318
+ this.syncProgress(0);
405
319
  }
406
- resetEmbeddedState() {
407
- if (this.previewUrl) {
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.countdownProgress = 0;
326
+ this.syncProgress(0);
416
327
  this.qualityResult = null;
417
328
  this.capturedBlob = null;
329
+ this.captureMode = 'auto';
418
330
  }
419
- resetPopupState() {
420
- this.captureState = 'idle';
421
- this.errorMessage = '';
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
- return 'quality-neutral';
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 new Promise((resolve, reject) => {
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
- captureVideoFrame(video) {
474
- if (!video.videoWidth || !video.videoHeight) {
475
- return Promise.reject(new Error('Camera preview is not ready yet.'));
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
- context.drawImage(video, 0, 0, canvas.width, canvas.height);
485
- return new Promise((resolve, reject) => {
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
- .embedded-shell {
409
+ .capture-shell {
535
410
  display: flex;
536
411
  flex-direction: column;
537
412
  gap: 16px;
538
413
  }
539
414
 
540
- .embedded-copy {
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, "countdownProgress", void 0);
616
+ ], SimFaceCapture.prototype, "qualityResult", void 0);
736
617
  __decorate([
737
618
  state()
738
- ], SimFaceCapture.prototype, "qualityResult", void 0);
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);