@simprints/simface-sdk 0.6.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.
@@ -1,94 +1,123 @@
1
1
  /**
2
2
  * Camera capture service.
3
3
  *
4
- * Uses realtime face guidance with automatic capture when supported and falls
5
- * back to a simpler manual capture flow when the browser cannot support it.
4
+ * Plans capture explicitly as an ordered fallback chain:
5
+ * auto camera -> manual camera -> media picker.
6
6
  */
7
- import { assessFaceQuality, assessFaceQualityForVideo, getVideoDetector } from './face-detection.js';
8
- import { AUTO_CAPTURE_ANALYSIS_INTERVAL_MS, AUTO_CAPTURE_COUNTDOWN_MS, CAPTURE_GUIDE_MASK_PATH, CAPTURE_GUIDE_PATH, autoCaptureCompleteMessage, autoCaptureCountdownMessage, } from '../shared/auto-capture.js';
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';
9
11
  const CAPTURE_DIALOG_Z_INDEX = '2147483647';
10
12
  /**
11
- * Opens the device camera and returns a confirmed image Blob, or null if cancelled.
13
+ * Opens the configured capture presentation and returns a confirmed image Blob,
14
+ * or null if the user cancels.
12
15
  */
13
- export async function captureFromCamera() {
14
- if (prefersNativeCameraCapture()) {
15
- return captureFromFileInput();
16
+ export async function captureFromCamera(options) {
17
+ const captureOptions = normalizeCaptureOptions(options);
18
+ if (captureOptions.presentation === 'embedded') {
19
+ return captureFromEmbeddedComponent(captureOptions);
16
20
  }
17
- if (!navigator.mediaDevices?.getUserMedia) {
18
- throw new Error('In-browser camera capture is not supported in this browser.');
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
+ }
19
40
  }
20
- const mode = (await supportsRealtimeAutoCapture()) ? 'auto' : 'manual';
21
- return captureFromMediaDevices(mode);
41
+ throw new Error('No supported capture strategy is available in this environment.');
22
42
  }
23
- async function supportsRealtimeAutoCapture() {
24
- if (typeof window.requestAnimationFrame !== 'function' ||
25
- typeof window.cancelAnimationFrame !== 'function') {
26
- return false;
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);
27
52
  }
28
- if (!document.createElement('canvas').getContext('2d')) {
29
- return false;
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.');
30
92
  }
31
- try {
32
- await getVideoDetector();
33
- return true;
93
+ if (container instanceof HTMLElement) {
94
+ return container;
34
95
  }
35
- catch {
36
- return false;
96
+ const element = document.querySelector(container);
97
+ if (!element) {
98
+ throw new Error(`No element matched the embedded capture container selector "${container}".`);
37
99
  }
100
+ return element;
38
101
  }
39
- async function captureFromMediaDevices(initialMode) {
40
- let stream;
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
- };
102
+ async function captureFromPopupCamera(initialMode) {
58
103
  return new Promise((resolve, reject) => {
59
104
  let settled = false;
60
- let mode = initialMode;
61
105
  let overlay = null;
62
106
  let previewUrl = '';
63
- let previewBlob = null;
64
- let animationFrameId = null;
107
+ let stream = null;
108
+ let controller = null;
65
109
  let escapeHandler = null;
66
- let lastAnalysisTimestamp = 0;
67
- let analysisInFlight = false;
68
- let previewActive = false;
69
- let videoReady = false;
70
- let countdownStartedAt = null;
71
- let bestCaptureBlob = null;
72
- let bestCaptureScore = -1;
73
- let bestQualityResult = null;
74
- const resetAutoCaptureState = (guideOverlay) => {
75
- countdownStartedAt = null;
76
- bestCaptureBlob = null;
77
- bestCaptureScore = -1;
78
- bestQualityResult = null;
79
- guideOverlay.setProgress(0);
80
- };
81
110
  const cleanup = () => {
82
- if (animationFrameId !== null) {
83
- window.cancelAnimationFrame(animationFrameId);
84
- }
111
+ controller?.stop();
85
112
  if (escapeHandler) {
86
113
  window.removeEventListener('keydown', escapeHandler);
87
114
  }
88
115
  if (previewUrl) {
89
116
  URL.revokeObjectURL(previewUrl);
90
117
  }
91
- stopStream();
118
+ if (stream) {
119
+ stream.getTracks().forEach((track) => track.stop());
120
+ }
92
121
  overlay?.remove();
93
122
  };
94
123
  const finalize = (value, error) => {
@@ -103,73 +132,6 @@ async function captureFromMediaDevices(initialMode) {
103
132
  }
104
133
  resolve(value);
105
134
  };
106
- const renderCaptureMode = (video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton, guideOverlay) => {
107
- previewActive = false;
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
135
  try {
174
136
  overlay = document.createElement('div');
175
137
  overlay.setAttribute('data-simface-camera-overlay', 'true');
@@ -224,11 +186,11 @@ async function captureFromMediaDevices(initialMode) {
224
186
  video.autoplay = true;
225
187
  video.muted = true;
226
188
  video.playsInline = true;
227
- video.srcObject = stream;
228
189
  applyStyles(video, {
229
190
  width: '100%',
230
191
  height: '100%',
231
192
  objectFit: 'cover',
193
+ transform: 'scaleX(-1)',
232
194
  });
233
195
  const feedback = document.createElement('div');
234
196
  applyStyles(feedback, {
@@ -247,223 +209,144 @@ async function captureFromMediaDevices(initialMode) {
247
209
  cancelButton.dataset.simfaceAction = 'cancel';
248
210
  const captureButton = createActionButton('Take photo', 'primary');
249
211
  captureButton.dataset.simfaceAction = 'capture';
250
- captureButton.style.display = mode === 'manual' ? 'inline-flex' : 'none';
251
- captureButton.disabled = true;
252
- const guideOverlay = createGuideOverlay();
253
212
  const confirmButton = createActionButton('Use photo', 'primary');
254
213
  confirmButton.dataset.simfaceAction = 'confirm';
255
214
  const retakeButton = createActionButton('Retake', 'secondary');
256
215
  retakeButton.dataset.simfaceAction = 'retake';
216
+ const guideOverlay = createGuideOverlay();
257
217
  panel.append(title, copy, mediaContainer, feedback, actions);
258
218
  overlay.append(panel);
259
219
  document.body.appendChild(overlay);
260
- renderCaptureMode(video, mediaContainer, title, copy, feedback, actions, cancelButton, captureButton, guideOverlay);
261
220
  escapeHandler = (event) => {
262
221
  if (event.key === 'Escape') {
263
222
  finalize(null);
264
223
  }
265
224
  };
225
+ window.addEventListener('keydown', escapeHandler);
266
226
  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);
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
+ });
273
231
  });
274
232
  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
- }
233
+ void controller?.retake().catch((error) => {
234
+ finalize(null, error instanceof Error ? error : new Error('Failed to restart the capture.'));
235
+ });
282
236
  });
283
- captureButton.addEventListener('click', async () => {
284
- if (previewActive) {
285
- return;
286
- }
237
+ confirmButton.addEventListener('click', () => {
287
238
  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);
239
+ const blob = controller?.confirm();
240
+ finalize(blob ?? null);
291
241
  }
292
242
  catch (error) {
293
- finalize(null, error instanceof Error ? error : new Error('Failed to capture an image.'));
243
+ finalize(null, error instanceof Error ? error : new Error('Failed to confirm the photo.'));
294
244
  }
295
245
  });
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.'));
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
+ },
308
277
  });
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();
278
+ void (async () => {
279
+ try {
280
+ stream = await openUserFacingCameraStream();
281
+ if (settled) {
282
+ stream.getTracks().forEach((track) => track.stop());
318
283
  return;
319
284
  }
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
- }
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
+ })();
371
292
  }
372
293
  catch (error) {
373
294
  finalize(null, error instanceof Error ? error : new Error('Failed to open the camera capture UI.'));
374
295
  }
375
296
  });
376
297
  }
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);
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)',
388
315
  });
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();
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);
401
326
  }
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
327
  return;
413
328
  }
414
- await new Promise((resolve, reject) => {
415
- const handleReady = () => {
416
- cleanup();
417
- resolve();
418
- };
419
- const handleError = () => {
420
- cleanup();
421
- reject(new Error('Failed to start the camera preview.'));
422
- };
423
- const cleanup = () => {
424
- video.removeEventListener('loadedmetadata', handleReady);
425
- video.removeEventListener('error', handleError);
426
- };
427
- video.addEventListener('loadedmetadata', handleReady, { once: true });
428
- video.addEventListener('error', handleError, { once: true });
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;
449
- }
450
- resolve(blob);
451
- }, 'image/jpeg', 0.92);
452
- });
453
- }
454
- function resumeVideoPreview(video) {
455
- void video.play().catch(() => {
456
- // Ignore resume failures here; capture flow already handles preview startup errors.
457
- });
458
- }
459
- async function assessCapturedBlobSafely(blob) {
460
- try {
461
- const image = await blobToImage(blob);
462
- 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.';
463
334
  }
464
- catch {
465
- return null;
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.';
466
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);
467
350
  }
468
351
  function createActionButton(label, variant) {
469
352
  const button = document.createElement('button');
@@ -565,52 +448,5 @@ function setFeedbackState(element, state) {
565
448
  function applyStyles(element, styles) {
566
449
  Object.assign(element.style, styles);
567
450
  }
568
- function describeCameraError(error) {
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
- }
451
+ export { blobToImage, blobToDataURL };
616
452
  //# sourceMappingURL=camera.js.map