@smileid/web-components 1.4.7 → 1.5.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,968 +1,1011 @@
1
- import { IMAGE_TYPE } from '../../../../domain/constants/src/Constants';
2
- import SmartCamera from '../../../../domain/camera/src/SmartCamera';
3
- import styles from '../../../../styles/src/styles';
4
- import packageJson from '../../../../package.json';
5
- import '../../../navigation/src';
6
-
7
- const COMPONENTS_VERSION = packageJson.version;
8
-
9
- const DEFAULT_NO_OF_LIVENESS_FRAMES = 8;
10
-
11
- function hasMoreThanNColors(data, n = 16) {
12
- const colors = new Set();
13
- for (let i = 0; i < Math.min(data.length, 10000); i += 4) {
14
- // eslint-disable-next-line no-bitwise
15
- colors.add((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
16
- if (colors.size > n) {
17
- return true;
18
- }
19
- }
20
- return false;
21
- }
22
-
23
- function getLivenessFramesIndices(
24
- totalNoOfFrames,
25
- numberOfFramesRequired = DEFAULT_NO_OF_LIVENESS_FRAMES,
26
- ) {
27
- const selectedFrames = [];
28
-
29
- if (totalNoOfFrames < numberOfFramesRequired) {
30
- throw new Error(
31
- 'SmartCameraWeb: Minimum required no of frames is ',
32
- numberOfFramesRequired,
33
- );
34
- }
35
-
36
- const frameDivisor = numberOfFramesRequired - 1;
37
- const frameInterval = Math.floor(totalNoOfFrames / frameDivisor);
38
-
39
- // NOTE: when we have satisfied our required 8 frames, but have good
40
- // candidates, we need to start replacing from the second frame
41
- let replacementFrameIndex = 1;
42
-
43
- for (let i = 0; i < totalNoOfFrames; i += frameInterval) {
44
- if (selectedFrames.length < 8) {
45
- selectedFrames.push(i);
46
- } else {
47
- // ACTION: replace frame, then sort selectedframes
48
- selectedFrames[replacementFrameIndex] = i;
49
- selectedFrames.sort((a, b) => a - b);
50
-
51
- // ACTION: update replacement frame index
52
- replacementFrameIndex += 1;
53
- }
54
- }
55
-
56
- // INFO: if we don't satisfy our requirement, we add the last index
57
- const lastFrameIndex = totalNoOfFrames - 1;
58
-
59
- if (selectedFrames.length < 8 && !selectedFrames.includes(lastFrameIndex)) {
60
- selectedFrames.push(lastFrameIndex);
61
- }
62
-
63
- return selectedFrames;
64
- }
65
-
66
- function templateString() {
67
- return `
68
- ${styles(this.themeColor)}
69
- <style>
70
- :host {
71
- --theme-color: ${this.themeColor || '#001096'};
72
- --color-active: #001096;
73
- --color-default: #2D2B2A;
74
- --color-disabled: #848282;
75
- }
76
-
77
- * {
78
- font-family: 'DM Sans', sans-serif;
79
- }
80
-
81
- [hidden] {
82
- display: none !important;
83
- }
84
-
85
- [disabled] {
86
- cursor: not-allowed !important;
87
- filter: grayscale(75%);
88
- }
89
-
90
- .visually-hidden {
91
- border: 0;
92
- clip: rect(1px 1px 1px 1px);
93
- clip: rect(1px, 1px, 1px, 1px);
94
- height: auto;
95
- margin: 0;
96
- overflow: hidden;
97
- padding: 0;
98
- position: absolute;
99
- white-space: nowrap;
100
- width: 1px;
101
- }
102
-
103
- img {
104
- height: auto;
105
- max-width: 100%;
106
- transform: scaleX(-1);
107
- }
108
-
109
- video {
110
- background-color: black;
111
- }
112
-
113
- a {
114
- color: currentColor;
115
- text-decoration: none;
116
- }
117
-
118
- svg {
119
- max-width: 100%;
120
- }
121
-
122
- .color-gray {
123
- color: #797979;
124
- }
125
-
126
- .color-red {
127
- color: red;
128
- }
129
-
130
- .color-richblue {
131
- color: #4E6577;
132
- }
133
-
134
- .color-richblue-shade {
135
- color: #0E1B42;
136
- }
137
-
138
- .color-digital-blue {
139
- color: #001096 !important;
140
- }
141
-
142
- .color-deep-blue {
143
- color: #001096;
144
- }
145
-
146
- .title-color {
147
- color: ${this.themeColor};
148
- }
149
-
150
- .theme-color {
151
- color: ${this.themeColor};
152
- }
153
-
154
- .center {
155
- text-align: center;
156
- margin-left: auto;
157
- margin-right: auto;
158
- }
159
-
160
- .font-size-small {
161
- font-size: .75rem;
162
- }
163
-
164
- .font-size-large {
165
- font-size: 1.5rem;
166
- }
167
-
168
- .text-transform-uppercase {
169
- text-transform: uppercase;
170
- }
171
-
172
- [id*=-"screen"] {
173
- min-block-size: 100%;
174
- }
175
-
176
- [data-variant~="full-width"] {
177
- inline-size: 100%;
178
- }
179
-
180
- .flow > * + * {
181
- margin-top: 1rem;
182
- }
183
-
184
- .button {
185
- --button-color: ${this.themeColor};
186
- -webkit-appearance: none;
187
- appearance: none;
188
- border-radius: 2.5rem;
189
- border: 0;
190
- background-color: transparent;
191
- color: #fff;
192
- cursor: pointer;
193
- display: block;
194
- font-size: 18px;
195
- font-weight: 600;
196
- padding: .75rem 1.5rem;
197
- text-align: center;
198
- }
199
-
200
- .button:hover,
201
- .button:focus,
202
- .button:active {
203
- --button-color: var(--color-default);
204
- }
205
-
206
- .button:disabled {
207
- --button-color: var(--color-disabled);
208
- }
209
-
210
- .button[data-variant~='solid'] {
211
- background-color: var(--button-color);
212
- border: 2px solid var(--button-color);
213
- }
214
-
215
- .button[data-variant~='outline'] {
216
- color: var(--button-color);
217
- border: 2px solid var(--button-color);
218
- }
219
-
220
- .button[data-variant~='ghost'] {
221
- padding: 0px;
222
- color: var(--button-color);
223
- background-color: transparent;
224
- }
225
-
226
- .icon-btn {
227
- appearance: none;
228
- background: none;
229
- border: none;
230
- color: hsl(0deg 0% 94%);
231
- cursor: pointer;
232
- display: flex;
233
- align-items: center;
234
- justify-content: center;
235
- padding: 4px 8px;
236
- }
237
- .justify-right {
238
- justify-content: end !important;
239
- }
240
- .nav {
241
- display: flex;
242
- justify-content: space-between;
243
- }
244
-
245
- .back-wrapper {
246
- display: flex;
247
- align-items: center;
248
- }
249
-
250
- .back-button {
251
- display: block !important;
252
- }
253
- .back-button-text {
254
- font-size: 11px;
255
- line-height: 11px;
256
- color: rgb(21, 31, 114);
257
- }
258
- .section {
259
- border-radius: .5rem;
260
- margin-left: auto;
261
- margin-right: auto;
262
- max-width: 35ch;
263
- padding: 1rem;
264
- }
265
-
266
- .selfie-capture-review-image {
267
- overflow: hidden;
268
- aspect-ratio: 1/1;
269
- }
270
-
271
- #review-image {
272
- scale: 1.75;
273
- }
274
-
275
- @media (max-aspect-ratio: 1/1) {
276
- #review-image {
277
- transform: scaleX(-1) translateY(-10%);
278
- }
279
- }
280
-
281
- .tips,
282
- .powered-by {
283
- align-items: center;
284
- border-radius: .25rem;
285
- color: #4E6577;
286
- display: flex;
287
- justify-content: center;
288
- letter-spacing: .075em;
289
- }
290
-
291
- .powered-by {
292
- box-shadow: 0px 2.57415px 2.57415px rgba(0, 0, 0, 0.06);
293
- display: inline-flex;
294
- font-size: .5rem;
295
- }
296
-
297
- .tips {
298
- margin-left: auto;
299
- margin-right: auto;
300
- max-width: 17rem;
301
- }
302
-
303
- .tips > * + *,
304
- .powered-by > * + * {
305
- display: inline-block;
306
- margin-left: .5em;
307
- }
308
-
309
- .powered-by .company {
310
- color: #18406D;
311
- font-weight: 700;
312
- letter-spacing: .15rem;
313
- }
314
-
315
- .logo-mark {
316
- background-color: #004071;
317
- display: inline-block;
318
- padding: .25em .5em;
319
- }
320
-
321
- .logo-mark svg {
322
- height: auto;
323
- justify-self: center;
324
- width: .75em;
325
- }
326
-
327
- @keyframes fadeInOut {
328
- 12.5% {
329
- opacity: 0;
330
- }
331
-
332
- 50% {
333
- opacity: 1;
334
- }
335
-
336
- 87.5% {
337
- opacity: 0;
338
- }
339
- }
340
-
341
- .id-video-container.portrait {
342
- width: 100%;
343
- position: relative;
344
- height: calc(200px * 1.4);
345
- }
346
-
347
- .id-video-container.portrait video {
348
- width: calc(213px + 0.9rem);
349
- height: 100%;
350
- position: absolute;
351
- top: 239px;
352
- left: 161px;
353
- padding-bottom: calc((214px * 1.4) / 3);
354
- padding-top: calc((191px * 1.4) / 3);
355
- object-fit: cover;
356
-
357
- transform: translateX(-50%) translateY(-50%);
358
- z-index: 1;
359
- block-size: 100%;
360
- }
361
-
362
- .video-container,
363
- .id-video-container.landscape {
364
- position: relative;
365
- z-index: 1;
366
- width: 100%;
367
- }
368
-
369
- .video-container #smile-cta,
370
- .video-container video,
371
- .id-video-container.landscape video {
372
- left: 50%;
373
- min-width: auto;
374
- position: absolute;
375
- top: calc(50% - 3px);
376
- transform: translateX(-50%) translateY(50%);
377
- }
378
-
379
- .video-container #smile-cta {
380
- color: white;
381
- font-size: 2rem;
382
- font-weight: bold;
383
- opacity: 0;
384
- top: calc(50% - 3rem);
385
- }
386
-
387
- .video-container video {
388
- min-height: 100%;
389
- transform: scaleX(-1) translateX(50%) translateY(-50%);
390
- }
391
-
392
- .video-container video.agent-mode {
393
- min-height: 100%;
394
- transform: scaleX(1) translateX(-50%) translateY(-50%);
395
- }
396
-
397
- .video-container .video {
398
- background-color: black;
399
- position: absolute;
400
- left: 50%;
401
- height: calc(100% - 6px);
402
- clip-path: ellipse(101px 118px);
403
- }
404
-
405
- .id-video-container.landscape {
406
- min-height: calc((2 * 10rem) + 198px);
407
- height: auto;
408
- }
409
-
410
- .id-video-container.portrait .image-frame-portrait {
411
- border-width: 0.9rem;
412
- border-color: rgba(0, 0, 0, 0.7);
413
- border-style: solid;
414
- height: auto;
415
- position: absolute;
416
- top: 80px;
417
- left: 47px;
418
- z-index: 2;
419
- width: 200px;
420
- height: calc(200px * 1.4);
421
- }
422
-
423
- .id-video-container.landscape .image-frame {
424
- border-width: 10rem 1rem;
425
- border-color: rgba(0, 0, 0, 0.7);
426
- border-style: solid;
427
- height: auto;
428
- width: 90%;
429
- position: absolute;
430
- top: 0;
431
- left: 0;
432
- z-index: 2;
433
- }
434
-
435
- .id-video-container.landscape video {
436
- width: 100%;
437
- transform: translateX(-50%) translateY(-50%);
438
- z-index: 1;
439
- height: 100%;
440
- block-size: 100%;
441
- }
442
-
443
- .id-video-container.landscape img {
444
- position: absolute;
445
- top: 50%;
446
- left: 50%;
447
- transform: translateX(-50%) translateY(-50%);
448
- max-width: 90%;
449
- }
450
-
451
- .actions {
452
- background-color: rgba(0, 0, 0, .7);
453
- bottom: 0;
454
- display: flex;
455
- justify-content: space-between;
456
- padding: 1rem;
457
- position: absolute;
458
- width: 90%;
459
- z-index: 2;
460
- }
461
-
462
- #back-of-id-camera-screen .id-video-container.portrait .actions,
463
- #id-camera-screen .id-video-container.portrait .actions {
464
- top: 145%;
465
- width: calc(200px * 1.4);
466
- }
467
-
468
- #back-of-id-camera-screen .section.portrait, #id-camera-screen .section.portrait {
469
- min-height: calc((200px * 1.4) + 260px);
470
- }
471
-
472
- #selfie-capture-screen,
473
- #back-of-id-entry-screen {
474
- block-size: 45rem;
475
- padding-block: 2rem;
476
- display: flex;
477
- flex-direction: column;
478
- max-block-size: 100%;
479
- max-inline-size: 40ch;
480
- }
481
-
482
- #selfie-capture-screen header p {
483
- margin-block: 0 !important;
484
- }
485
-
486
- .document-tips {
487
- margin-block-start: 1.5rem;
488
- display: flex;
489
- align-items: center;
490
- text-align: initial;
491
- }
492
-
493
- .document-tips svg {
494
- flex-shrink: 0;
495
- margin-inline-end: 1rem;
496
- }
497
-
498
- .document-tips p {
499
- margin-block: 0;
500
- }
501
-
502
- .document-tips p:first-of-type {
503
- font-size; 1.875rem;
504
- font-weight: bold
505
- }
506
-
507
- [type='file'] {
508
- display: none;
509
- }
510
-
511
- .document-tips > * + * {
512
- margin-inline-start; 1em;
513
- }
514
- </style>
515
- <div id='selfie-capture-screen' class='flow center'>
516
- <smileid-navigation theme-color='${this.themeColor}' ${this.showNavigation ? 'show-navigation' : ''} ${this.hideBack ? 'hide-back' : ''}></smileid-navigation>
517
- <h1 class='text-2xl title-color font-bold'>Take a Selfie</h1>
518
-
519
- <div className="error">
520
- ${this.cameraError ? `<p class="color-red">${this.cameraError}</p>` : ''}
521
- ${this.hideAttribution ? '' : '<powered-by-smile-id></powered-by-smile-id>'}
522
- </div>
523
- <div class='section | flow' ${this.cameraError ? 'hidden' : ''}>
524
- <div class='video-container'>
525
- <div class='video'>
526
- </div>
527
- <svg id="image-outline" width="215" height="245" viewBox="0 0 215 245" fill="none" xmlns="http://www.w3.org/2000/svg">
528
- <path d="M210.981 122.838C210.981 188.699 164.248 241.268 107.55 241.268C50.853 241.268 4.12018 188.699 4.12018 122.838C4.12018 56.9763 50.853 4.40771 107.55 4.40771C164.248 4.40771 210.981 56.9763 210.981 122.838Z" stroke="${this.themeColor}" stroke-width="7.13965"/>
529
- </svg>
530
- <p id='smile-cta' class='color-gray'>SMILE</p>
531
- </div>
532
-
533
- <small class='tips'>
534
- <svg width='44' xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 40 40">
535
- <path fill="#F8F8FA" fill-rule="evenodd" d="M17.44 0h4.2c4.92 0 7.56.68 9.95 1.96a13.32 13.32 0 015.54 5.54c1.27 2.39 1.95 5.02 1.95 9.94v4.2c0 4.92-.68 7.56-1.95 9.95a13.32 13.32 0 01-5.54 5.54c-2.4 1.27-5.03 1.95-9.95 1.95h-4.2c-4.92 0-7.55-.68-9.94-1.95a13.32 13.32 0 01-5.54-5.54C.68 29.19 0 26.56 0 21.64v-4.2C0 12.52.68 9.9 1.96 7.5A13.32 13.32 0 017.5 1.96C9.89.68 12.52 0 17.44 0z" clip-rule="evenodd"/>
536
- <path fill="#AEB6CB" d="M19.95 10.58a.71.71 0 000 1.43.71.71 0 000-1.43zm-5.54 2.3a.71.71 0 000 1.43.71.71 0 000-1.43zm11.08 0a.71.71 0 000 1.43.71.71 0 000-1.43zm-5.63 1.27a4.98 4.98 0 00-2.05 9.48v1.2a2.14 2.14 0 004.28 0v-1.2a4.99 4.99 0 00-2.23-9.48zm-7.75 4.27a.71.71 0 000 1.43.71.71 0 000-1.43zm15.68 0a.71.71 0 000 1.43.71.71 0 000-1.43z"/>
537
- </svg>
538
- <span>Tips: Put your face inside the oval frame and click to "take selfie"</span> </small>
539
-
540
- ${this.allowAgentMode ? `<button data-variant='outline small' id='switch-camera' class='button | center' type='button'>${this.inAgentMode ? 'Agent Mode On' : 'Agent Mode Off'}</button>` : ''}
541
-
542
- <button data-variant='solid' id='start-image-capture' class='button | center' type='button'>
543
- Take Selfie
544
- </button>
545
-
546
- ${this.hideAttribution ? '' : '<powered-by-smile-id></powered-by-smile-id>'}
547
- </div>
548
- </div>
549
- `;
550
- }
551
-
552
- async function getPermissions(
553
- captureScreen,
554
- constraints = { facingMode: 'user' },
555
- ) {
556
- try {
557
- await SmartCamera.getMedia({
558
- audio: false,
559
- video: constraints,
560
- });
561
- captureScreen?.removeAttribute('data-camera-error');
562
- captureScreen?.setAttribute('data-camera-ready', true);
563
- } catch (error) {
564
- captureScreen?.removeAttribute('data-camera-ready');
565
- captureScreen?.setAttribute(
566
- 'data-camera-error',
567
- SmartCamera.handleCameraError(error),
568
- );
569
- }
570
- }
571
-
572
- class SelfieCaptureScreen extends HTMLElement {
573
- constructor() {
574
- super();
575
- this.templateString = templateString.bind(this);
576
- this.render = () => this.templateString();
577
-
578
- this.attachShadow({ mode: 'open' });
579
- this.facingMode = 'user';
580
- if (this.allowAgentMode) {
581
- this.facingMode = 'environment';
582
- }
583
- }
584
-
585
- connectedCallback() {
586
- const template = document.createElement('template');
587
- template.innerHTML = this.render();
588
- this.shadowRoot.innerHTML = '';
589
- this.shadowRoot.appendChild(template.content.cloneNode(true));
590
- this.videoContainer = this.shadowRoot.querySelector(
591
- '.video-container > .video',
592
- );
593
- this.init();
594
- }
595
-
596
- init() {
597
- this._videoStreamDurationInMS = 7800;
598
- this._imageCaptureIntervalInMS = 200;
599
-
600
- this._data = {
601
- images: [],
602
- meta: {
603
- libraryVersion: COMPONENTS_VERSION,
604
- },
605
- };
606
- this._rawImages = [];
607
-
608
- this.setUpEventListeners();
609
- }
610
-
611
- reset() {
612
- this.disconnectedCallback();
613
- this.connectedCallback();
614
- }
615
-
616
- _startImageCapture() {
617
- this.startImageCapture.disabled = true;
618
- if (this.switchCamera) {
619
- this.switchCamera.disabled = true;
620
- }
621
-
622
- /**
623
- * this was culled from https://jakearchibald.com/2013/animated-line-drawing-svg/
624
- */
625
- // NOTE: initialise image outline
626
- const imageOutlineLength = this.imageOutline.getTotalLength();
627
- // Clear any previous transition
628
- this.imageOutline.style.transition = 'none';
629
- // Set up the starting positions
630
- this.imageOutline.style.strokeDasharray = `${imageOutlineLength} ${imageOutlineLength}`;
631
- this.imageOutline.style.strokeDashoffset = imageOutlineLength;
632
- // Trigger a layout so styles are calculated & the browser
633
- // picks up the starting position before animating
634
- this.imageOutline.getBoundingClientRect();
635
- // Define our transition
636
- this.imageOutline.style.transition = `stroke-dashoffset ${this._videoStreamDurationInMS / 1000}s ease-in-out`;
637
- // Go!
638
- this.imageOutline.style.strokeDashoffset = '0';
639
-
640
- this.smileCTA.style.animation = `fadeInOut ease ${this._videoStreamDurationInMS / 1000}s`;
641
-
642
- this._imageCaptureInterval = setInterval(() => {
643
- this._capturePOLPhoto();
644
- }, this._imageCaptureIntervalInMS);
645
-
646
- this._videoStreamTimeout = setTimeout(() => {
647
- this._stopVideoStream();
648
- }, this._videoStreamDurationInMS);
649
- }
650
-
651
- async _switchCamera() {
652
- this.facingMode = this.facingMode === 'user' ? 'environment' : 'user';
653
- if (this.facingMode === 'user') {
654
- this.shadowRoot.querySelector('video').classList.remove('agent-mode');
655
- } else {
656
- this.shadowRoot.querySelector('video').classList.add('agent-mode');
657
- }
658
- this.startImageCapture.disabled = true;
659
- this.switchCamera.disabled = true;
660
- SmartCamera.stopMedia();
661
- await getPermissions(this, { facingMode: this.facingMode });
662
- this.handleStream(SmartCamera.stream);
663
- }
664
-
665
- _stopVideoStream() {
666
- try {
667
- clearTimeout(this._videoStreamTimeout);
668
- clearInterval(this._imageCaptureInterval);
669
- clearInterval(this._drawingInterval);
670
- this.smileCTA.style.animation = 'none';
671
-
672
- this._capturePOLPhoto(); // NOTE: capture the last photo
673
- this._captureReferencePhoto();
674
- SmartCamera.stopMedia();
675
-
676
- const totalNoOfFrames = this._rawImages.length;
677
- this._data.referenceImage = this._referenceImage;
678
- this._data.previewImage = this._referenceImage;
679
-
680
- const livenessFramesIndices = getLivenessFramesIndices(totalNoOfFrames);
681
-
682
- this._data.images = this._data.images.concat(
683
- livenessFramesIndices.map((imageIndex) => ({
684
- image: this._rawImages[imageIndex].split(',')[1],
685
- image_type_id: IMAGE_TYPE.LIVENESS_IMAGE_BASE64,
686
- })),
687
- );
688
-
689
- this._publishImages();
690
- } catch (error) {
691
- console.error(error);
692
- // Todo: handle error
693
- }
694
- }
695
-
696
- _capturePOLPhoto() {
697
- const canvas = document.createElement('canvas');
698
- canvas.width = 240;
699
- canvas.height =
700
- (canvas.width * this._video.videoHeight) / this._video.videoWidth;
701
-
702
- // NOTE: we do not want to test POL images
703
- this._drawImage(canvas, false);
704
-
705
- this._rawImages.push(canvas.toDataURL('image/jpeg'));
706
- }
707
-
708
- _captureReferencePhoto() {
709
- const canvas = document.createElement('canvas');
710
- canvas.width = 480;
711
- canvas.height =
712
- (canvas.width * this._video.videoHeight) / this._video.videoWidth;
713
-
714
- // NOTE: we want to test the image quality of the reference photo
715
- this._drawImage(canvas, !this.disableImageTests);
716
-
717
- const image = canvas.toDataURL('image/jpeg');
718
-
719
- this._referenceImage = image;
720
-
721
- this._data.images.push({
722
- image: image.split(',')[1],
723
- image_type_id: IMAGE_TYPE.SELFIE_IMAGE_BASE64,
724
- });
725
- }
726
-
727
- _publishImages() {
728
- this.dispatchEvent(
729
- new CustomEvent('selfie-capture.publish', {
730
- detail: this._data,
731
- }),
732
- );
733
- }
734
-
735
- resetErrorMessage() {
736
- this.errorMessage.textContent = '';
737
- }
738
-
739
- _drawImage(canvas, enableImageTests = true, video = this._video) {
740
- // this.resetErrorMessage();
741
- const context = canvas.getContext('2d');
742
-
743
- context.drawImage(
744
- video,
745
- 0,
746
- 0,
747
- video.videoWidth,
748
- video.videoHeight,
749
- 0,
750
- 0,
751
- canvas.width,
752
- canvas.height,
753
- );
754
-
755
- if (enableImageTests) {
756
- const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
757
-
758
- const hasEnoughColors = hasMoreThanNColors(imageData.data);
759
-
760
- if (hasEnoughColors) {
761
- return context;
762
- }
763
- throw new Error(
764
- 'Unable to capture webcam images - Please try another device',
765
- );
766
- } else {
767
- return context;
768
- }
769
- }
770
-
771
- handleStream(stream) {
772
- try {
773
- const videoExists = this.shadowRoot.querySelector('video');
774
- let video = null;
775
- if (videoExists) {
776
- video = this.shadowRoot.querySelector('video');
777
- } else {
778
- video = document.createElement('video');
779
- }
780
-
781
- video.autoplay = true;
782
- video.playsInline = true;
783
- video.muted = true;
784
-
785
- if ('srcObject' in video) {
786
- video.srcObject = stream;
787
- } else {
788
- video.src = window.URL.createObjectURL(stream);
789
- }
790
-
791
- video.onloadedmetadata = () => {
792
- video.play();
793
- };
794
-
795
- this._video = video;
796
- const videoContainer = this.shadowRoot.querySelector(
797
- '.video-container > .video',
798
- );
799
- this._data.permissionGranted = true;
800
-
801
- if (!videoExists) {
802
- videoContainer.prepend(video);
803
- }
804
- } catch (error) {
805
- this.setAttribute(
806
- 'data-camera-error',
807
- SmartCamera.handleCameraError(error),
808
- );
809
- if (error.name !== 'AbortError') {
810
- console.error(error);
811
- }
812
- SmartCamera.stopMedia();
813
- }
814
- }
815
-
816
- setUpEventListeners() {
817
- this.navigation = this.shadowRoot.querySelector('smileid-navigation');
818
-
819
- this.startImageCapture = this.shadowRoot.querySelector(
820
- '#start-image-capture',
821
- );
822
-
823
- this.switchCamera = this.shadowRoot.querySelector('#switch-camera');
824
- this.imageOutline = this.shadowRoot.querySelector('#image-outline path');
825
- this.smileCTA = this.shadowRoot.querySelector('#smile-cta');
826
-
827
- this.startImageCapture.addEventListener('click', () => {
828
- this._startImageCapture();
829
- });
830
-
831
- this.switchCamera?.addEventListener('click', () => {
832
- this._switchCamera();
833
- });
834
-
835
- this.navigation.addEventListener('navigation.back', () => {
836
- this.handleBackEvents();
837
- });
838
-
839
- this.navigation.addEventListener('navigation.close', () => {
840
- this.closeWindow();
841
- });
842
-
843
- if (SmartCamera.stream) {
844
- this.handleStream(SmartCamera.stream);
845
- } else if (this.hasAttribute('data-camera-ready')) {
846
- getPermissions(this, { facingMode: this.facingMode });
847
- }
848
-
849
- this.setupAgentMode();
850
- }
851
-
852
- disconnectedCallback() {
853
- SmartCamera.stopMedia();
854
- clearTimeout(this._videoStreamTimeout);
855
- }
856
-
857
- get hideBack() {
858
- return this.hasAttribute('hide-back');
859
- }
860
-
861
- get showNavigation() {
862
- return this.hasAttribute('show-navigation');
863
- }
864
-
865
- get themeColor() {
866
- return this.getAttribute('theme-color') || '#001096';
867
- }
868
-
869
- get hideAttribution() {
870
- return this.hasAttribute('hide-attribution');
871
- }
872
-
873
- async setupAgentMode() {
874
- if (!this.allowAgentMode) {
875
- return;
876
- }
877
-
878
- const supportAgentMode = await SmartCamera.supportsAgentMode();
879
-
880
- if (supportAgentMode || this.hasAttribute('show-agent-mode-for-tests')) {
881
- this.switchCamera.hidden = false;
882
- if (this.facingMode === 'user') {
883
- this.shadowRoot.querySelector('video')?.classList?.remove('agent-mode');
884
- } else {
885
- this.shadowRoot.querySelector('video')?.classList?.add('agent-mode');
886
- }
887
- } else {
888
- this.switchCamera.hidden = true;
889
- }
890
- }
891
-
892
- get hasAgentSupport() {
893
- return this.hasAttribute('has-agent-support');
894
- }
895
-
896
- get title() {
897
- return this.getAttribute('title') || 'Submit Front of ID';
898
- }
899
-
900
- get hidden() {
901
- return this.getAttribute('hidden');
902
- }
903
-
904
- get cameraError() {
905
- return this.getAttribute('data-camera-error');
906
- }
907
-
908
- get disableImageTests() {
909
- return this.hasAttribute('disable-image-tests');
910
- }
911
-
912
- get allowAgentMode() {
913
- return this.getAttribute('allow-agent-mode') === 'true';
914
- }
915
-
916
- get inAgentMode() {
917
- return this.facingMode === 'environment';
918
- }
919
-
920
- static get observedAttributes() {
921
- return [
922
- 'allow-agent-mode',
923
- 'data-camera-error',
924
- 'data-camera-ready',
925
- 'disable-image-tests',
926
- 'hidden',
927
- 'hide-back-to-host',
928
- 'show-navigation',
929
- 'title',
930
- ];
931
- }
932
-
933
- attributeChangedCallback(name) {
934
- switch (name) {
935
- case 'data-camera-error':
936
- case 'data-camera-ready':
937
- case 'hidden':
938
- case 'title':
939
- case 'allow-agent-mode':
940
- this.shadowRoot.innerHTML = this.render();
941
- this.init();
942
- break;
943
- default:
944
- break;
945
- }
946
- }
947
-
948
- handleBackEvents() {
949
- this.stopMedia();
950
- this.dispatchEvent(new CustomEvent('selfie-capture.cancelled'));
951
- }
952
-
953
- closeWindow() {
954
- this.stopMedia();
955
- this.dispatchEvent(new CustomEvent('selfie-capture.close'));
956
- }
957
-
958
- stopMedia() {
959
- this.removeAttribute('data-camera-ready');
960
- SmartCamera.stopMedia();
961
- }
962
- }
963
-
964
- if ('customElements' in window && !customElements.get('selfie-capture')) {
965
- window.customElements.define('selfie-capture', SelfieCaptureScreen);
966
- }
967
-
968
- export default SelfieCaptureScreen;
1
+ import { IMAGE_TYPE } from '../../../../domain/constants/src/Constants';
2
+ import SmartCamera from '../../../../domain/camera/src/SmartCamera';
3
+ import styles from '../../../../styles/src/styles';
4
+ import packageJson from '../../../../package.json';
5
+ import '../../../navigation/src';
6
+
7
+ const COMPONENTS_VERSION = packageJson.version;
8
+
9
+ const DEFAULT_NO_OF_LIVENESS_FRAMES = 8;
10
+
11
+ function hasMoreThanNColors(data, n = 16) {
12
+ const colors = new Set();
13
+ for (let i = 0; i < Math.min(data.length, 10000); i += 4) {
14
+ // eslint-disable-next-line no-bitwise
15
+ colors.add((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
16
+ if (colors.size > n) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function getLivenessFramesIndices(
24
+ totalNoOfFrames,
25
+ numberOfFramesRequired = DEFAULT_NO_OF_LIVENESS_FRAMES,
26
+ ) {
27
+ const selectedFrames = [];
28
+
29
+ if (totalNoOfFrames < numberOfFramesRequired) {
30
+ throw new Error(
31
+ 'SmartCameraWeb: Minimum required no of frames is ',
32
+ numberOfFramesRequired,
33
+ );
34
+ }
35
+
36
+ const frameDivisor = numberOfFramesRequired - 1;
37
+ const frameInterval = Math.floor(totalNoOfFrames / frameDivisor);
38
+
39
+ // NOTE: when we have satisfied our required 8 frames, but have good
40
+ // candidates, we need to start replacing from the second frame
41
+ let replacementFrameIndex = 1;
42
+
43
+ for (let i = 0; i < totalNoOfFrames; i += frameInterval) {
44
+ if (selectedFrames.length < 8) {
45
+ selectedFrames.push(i);
46
+ } else {
47
+ // ACTION: replace frame, then sort selectedframes
48
+ selectedFrames[replacementFrameIndex] = i;
49
+ selectedFrames.sort((a, b) => a - b);
50
+
51
+ // ACTION: update replacement frame index
52
+ replacementFrameIndex += 1;
53
+ }
54
+ }
55
+
56
+ // INFO: if we don't satisfy our requirement, we add the last index
57
+ const lastFrameIndex = totalNoOfFrames - 1;
58
+
59
+ if (selectedFrames.length < 8 && !selectedFrames.includes(lastFrameIndex)) {
60
+ selectedFrames.push(lastFrameIndex);
61
+ }
62
+
63
+ return selectedFrames;
64
+ }
65
+
66
+ function templateString() {
67
+ return `
68
+ ${styles(this.themeColor)}
69
+ <style>
70
+ :host {
71
+ --theme-color: ${this.themeColor || '#001096'};
72
+ --color-active: #001096;
73
+ --color-default: #2D2B2A;
74
+ --color-disabled: #848282;
75
+ }
76
+
77
+ * {
78
+ font-family: 'DM Sans', sans-serif;
79
+ }
80
+
81
+ [hidden] {
82
+ display: none !important;
83
+ }
84
+
85
+ [disabled] {
86
+ cursor: not-allowed !important;
87
+ filter: grayscale(75%);
88
+ }
89
+
90
+ .visually-hidden {
91
+ border: 0;
92
+ clip: rect(1px 1px 1px 1px);
93
+ clip: rect(1px, 1px, 1px, 1px);
94
+ height: auto;
95
+ margin: 0;
96
+ overflow: hidden;
97
+ padding: 0;
98
+ position: absolute;
99
+ white-space: nowrap;
100
+ width: 1px;
101
+ }
102
+
103
+ img {
104
+ height: auto;
105
+ max-width: 100%;
106
+ transform: scaleX(-1);
107
+ }
108
+
109
+ video {
110
+ background-color: black;
111
+ }
112
+
113
+ a {
114
+ color: currentColor;
115
+ text-decoration: none;
116
+ }
117
+
118
+ svg {
119
+ max-width: 100%;
120
+ }
121
+
122
+ .color-gray {
123
+ color: #797979;
124
+ }
125
+
126
+ .color-red {
127
+ color: red;
128
+ }
129
+
130
+ .color-richblue {
131
+ color: #4E6577;
132
+ }
133
+
134
+ .color-richblue-shade {
135
+ color: #0E1B42;
136
+ }
137
+
138
+ .color-digital-blue {
139
+ color: #001096 !important;
140
+ }
141
+
142
+ .color-deep-blue {
143
+ color: #001096;
144
+ }
145
+
146
+ .title-color {
147
+ color: ${this.themeColor};
148
+ }
149
+
150
+ .theme-color {
151
+ color: ${this.themeColor};
152
+ }
153
+
154
+ .center {
155
+ text-align: center;
156
+ margin-left: auto;
157
+ margin-right: auto;
158
+ }
159
+
160
+ .font-size-small {
161
+ font-size: .75rem;
162
+ }
163
+
164
+ .font-size-large {
165
+ font-size: 1.5rem;
166
+ }
167
+
168
+ .text-transform-uppercase {
169
+ text-transform: uppercase;
170
+ }
171
+
172
+ [id*=-"screen"] {
173
+ min-block-size: 100%;
174
+ }
175
+
176
+ [data-variant~="full-width"] {
177
+ inline-size: 100%;
178
+ }
179
+
180
+ .flow > * + * {
181
+ margin-top: 1rem;
182
+ }
183
+
184
+ .button {
185
+ --button-color: ${this.themeColor};
186
+ -webkit-appearance: none;
187
+ appearance: none;
188
+ border-radius: 2.5rem;
189
+ border: 0;
190
+ background-color: transparent;
191
+ color: #fff;
192
+ cursor: pointer;
193
+ display: block;
194
+ font-size: 18px;
195
+ font-weight: 600;
196
+ padding: .75rem 1.5rem;
197
+ text-align: center;
198
+ }
199
+
200
+ .button:hover,
201
+ .button:focus,
202
+ .button:active {
203
+ --button-color: var(--color-default);
204
+ }
205
+
206
+ .button:disabled {
207
+ --button-color: var(--color-disabled);
208
+ }
209
+
210
+ .button[data-variant~='solid'] {
211
+ background-color: var(--button-color);
212
+ border: 2px solid var(--button-color);
213
+ }
214
+
215
+ .button[data-variant~='outline'] {
216
+ color: var(--button-color);
217
+ border: 2px solid var(--button-color);
218
+ }
219
+
220
+ .button[data-variant~='ghost'] {
221
+ padding: 0px;
222
+ color: var(--button-color);
223
+ background-color: transparent;
224
+ }
225
+
226
+ .icon-btn {
227
+ appearance: none;
228
+ background: none;
229
+ border: none;
230
+ color: hsl(0deg 0% 94%);
231
+ cursor: pointer;
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: center;
235
+ padding: 4px 8px;
236
+ }
237
+ .justify-right {
238
+ justify-content: end !important;
239
+ }
240
+ .nav {
241
+ display: flex;
242
+ justify-content: space-between;
243
+ }
244
+
245
+ .back-wrapper {
246
+ display: flex;
247
+ align-items: center;
248
+ }
249
+
250
+ .back-button {
251
+ display: block !important;
252
+ }
253
+ .back-button-text {
254
+ font-size: 11px;
255
+ line-height: 11px;
256
+ color: rgb(21, 31, 114);
257
+ }
258
+ .section {
259
+ border-radius: .5rem;
260
+ margin-left: auto;
261
+ margin-right: auto;
262
+ max-width: 35ch;
263
+ padding: 1rem;
264
+ }
265
+
266
+ .selfie-capture-review-image {
267
+ overflow: hidden;
268
+ aspect-ratio: 1/1;
269
+ }
270
+
271
+ #review-image {
272
+ scale: 1.75;
273
+ }
274
+
275
+ @media (max-aspect-ratio: 1/1) {
276
+ #review-image {
277
+ transform: scaleX(-1) translateY(-10%);
278
+ }
279
+ }
280
+
281
+ .tips,
282
+ .powered-by {
283
+ align-items: center;
284
+ border-radius: .25rem;
285
+ color: #4E6577;
286
+ display: flex;
287
+ justify-content: center;
288
+ letter-spacing: .075em;
289
+ }
290
+
291
+ .powered-by {
292
+ box-shadow: 0px 2.57415px 2.57415px rgba(0, 0, 0, 0.06);
293
+ display: inline-flex;
294
+ font-size: .5rem;
295
+ }
296
+
297
+ .tips {
298
+ margin-left: auto;
299
+ margin-right: auto;
300
+ max-width: 17rem;
301
+ }
302
+
303
+ .tips > * + *,
304
+ .powered-by > * + * {
305
+ display: inline-block;
306
+ margin-left: .5em;
307
+ }
308
+
309
+ .powered-by .company {
310
+ color: #18406D;
311
+ font-weight: 700;
312
+ letter-spacing: .15rem;
313
+ }
314
+
315
+ .logo-mark {
316
+ background-color: #004071;
317
+ display: inline-block;
318
+ padding: .25em .5em;
319
+ }
320
+
321
+ .logo-mark svg {
322
+ height: auto;
323
+ justify-self: center;
324
+ width: .75em;
325
+ }
326
+
327
+ @keyframes fadeInOut {
328
+ 12.5% {
329
+ opacity: 0;
330
+ }
331
+
332
+ 50% {
333
+ opacity: 1;
334
+ }
335
+
336
+ 87.5% {
337
+ opacity: 0;
338
+ }
339
+ }
340
+
341
+ .id-video-container.portrait {
342
+ width: 100%;
343
+ position: relative;
344
+ height: calc(200px * 1.4);
345
+ }
346
+
347
+ .id-video-container.portrait video {
348
+ width: calc(213px + 0.9rem);
349
+ height: 100%;
350
+ position: absolute;
351
+ top: 239px;
352
+ left: 161px;
353
+ padding-bottom: calc((214px * 1.4) / 3);
354
+ padding-top: calc((191px * 1.4) / 3);
355
+ object-fit: cover;
356
+
357
+ transform: translateX(-50%) translateY(-50%);
358
+ z-index: 1;
359
+ block-size: 100%;
360
+ }
361
+
362
+ .video-container,
363
+ .id-video-container.landscape {
364
+ position: relative;
365
+ z-index: 1;
366
+ width: 100%;
367
+ }
368
+
369
+ .video-container #smile-cta,
370
+ .video-container video,
371
+ .id-video-container.landscape video {
372
+ left: 50%;
373
+ min-width: auto;
374
+ position: absolute;
375
+ top: calc(50% - 3px);
376
+ transform: translateX(-50%) translateY(50%);
377
+ }
378
+
379
+ .video-container #smile-cta {
380
+ color: white;
381
+ font-size: 2rem;
382
+ font-weight: bold;
383
+ opacity: 0;
384
+ top: calc(50% - 3rem);
385
+ }
386
+
387
+ .video-container video {
388
+ min-height: 100%;
389
+ transform: scaleX(-1) translateX(50%) translateY(-50%);
390
+ }
391
+
392
+ .video-container video.agent-mode {
393
+ min-height: 100%;
394
+ transform: scaleX(1) translateX(-50%) translateY(-50%);
395
+ }
396
+
397
+ .video-container .video {
398
+ background-color: black;
399
+ position: absolute;
400
+ left: 50%;
401
+ height: calc(100% - 6px);
402
+ clip-path: ellipse(101px 118px);
403
+ }
404
+
405
+ .id-video-container.landscape {
406
+ min-height: calc((2 * 10rem) + 198px);
407
+ height: auto;
408
+ }
409
+
410
+ .id-video-container.portrait .image-frame-portrait {
411
+ border-width: 0.9rem;
412
+ border-color: rgba(0, 0, 0, 0.7);
413
+ border-style: solid;
414
+ height: auto;
415
+ position: absolute;
416
+ top: 80px;
417
+ left: 47px;
418
+ z-index: 2;
419
+ width: 200px;
420
+ height: calc(200px * 1.4);
421
+ }
422
+
423
+ .id-video-container.landscape .image-frame {
424
+ border-width: 10rem 1rem;
425
+ border-color: rgba(0, 0, 0, 0.7);
426
+ border-style: solid;
427
+ height: auto;
428
+ width: 90%;
429
+ position: absolute;
430
+ top: 0;
431
+ left: 0;
432
+ z-index: 2;
433
+ }
434
+
435
+ .id-video-container.landscape video {
436
+ width: 100%;
437
+ transform: translateX(-50%) translateY(-50%);
438
+ z-index: 1;
439
+ height: 100%;
440
+ block-size: 100%;
441
+ }
442
+
443
+ .id-video-container.landscape img {
444
+ position: absolute;
445
+ top: 50%;
446
+ left: 50%;
447
+ transform: translateX(-50%) translateY(-50%);
448
+ max-width: 90%;
449
+ }
450
+
451
+ .actions {
452
+ background-color: rgba(0, 0, 0, .7);
453
+ bottom: 0;
454
+ display: flex;
455
+ justify-content: space-between;
456
+ padding: 1rem;
457
+ position: absolute;
458
+ width: 90%;
459
+ z-index: 2;
460
+ }
461
+
462
+ #back-of-id-camera-screen .id-video-container.portrait .actions,
463
+ #id-camera-screen .id-video-container.portrait .actions {
464
+ top: 145%;
465
+ width: calc(200px * 1.4);
466
+ }
467
+
468
+ #back-of-id-camera-screen .section.portrait, #id-camera-screen .section.portrait {
469
+ min-height: calc((200px * 1.4) + 260px);
470
+ }
471
+
472
+ #selfie-capture-screen,
473
+ #back-of-id-entry-screen {
474
+ block-size: 45rem;
475
+ padding-block: 2rem;
476
+ display: flex;
477
+ flex-direction: column;
478
+ max-block-size: 100%;
479
+ max-inline-size: 40ch;
480
+ }
481
+
482
+ #selfie-capture-screen header p {
483
+ margin-block: 0 !important;
484
+ }
485
+
486
+ .document-tips {
487
+ margin-block-start: 1.5rem;
488
+ display: flex;
489
+ align-items: center;
490
+ text-align: initial;
491
+ }
492
+
493
+ .document-tips svg {
494
+ flex-shrink: 0;
495
+ margin-inline-end: 1rem;
496
+ }
497
+
498
+ .document-tips p {
499
+ margin-block: 0;
500
+ }
501
+
502
+ .document-tips p:first-of-type {
503
+ font-size; 1.875rem;
504
+ font-weight: bold
505
+ }
506
+
507
+ [type='file'] {
508
+ display: none;
509
+ }
510
+
511
+ .document-tips > * + * {
512
+ margin-inline-start; 1em;
513
+ }
514
+ </style>
515
+ <div id='selfie-capture-screen' class='flow center'>
516
+ <smileid-navigation theme-color='${this.themeColor}' ${this.showNavigation ? 'show-navigation' : ''} ${this.hideBack ? 'hide-back' : ''}></smileid-navigation>
517
+ <h1 class='text-2xl title-color font-bold'>Take a Selfie</h1>
518
+
519
+ <div className="error">
520
+ ${this.cameraError ? `<p class="color-red">${this.cameraError}</p>` : ''}
521
+ ${this.hideAttribution ? '' : '<powered-by-smile-id></powered-by-smile-id>'}
522
+ </div>
523
+ <div class='section | flow' ${this.cameraError ? 'hidden' : ''}>
524
+ <div class='video-container'>
525
+ <div class='video'>
526
+ </div>
527
+ <svg id="image-outline" width="215" height="245" viewBox="0 0 215 245" fill="none" xmlns="http://www.w3.org/2000/svg">
528
+ <path d="M210.981 122.838C210.981 188.699 164.248 241.268 107.55 241.268C50.853 241.268 4.12018 188.699 4.12018 122.838C4.12018 56.9763 50.853 4.40771 107.55 4.40771C164.248 4.40771 210.981 56.9763 210.981 122.838Z" stroke="${this.themeColor}" stroke-width="7.13965"/>
529
+ </svg>
530
+ <p id='smile-cta' class='color-gray'>SMILE</p>
531
+ </div>
532
+
533
+ <small class='tips'>
534
+ <svg width='44' xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 40 40">
535
+ <path fill="#F8F8FA" fill-rule="evenodd" d="M17.44 0h4.2c4.92 0 7.56.68 9.95 1.96a13.32 13.32 0 015.54 5.54c1.27 2.39 1.95 5.02 1.95 9.94v4.2c0 4.92-.68 7.56-1.95 9.95a13.32 13.32 0 01-5.54 5.54c-2.4 1.27-5.03 1.95-9.95 1.95h-4.2c-4.92 0-7.55-.68-9.94-1.95a13.32 13.32 0 01-5.54-5.54C.68 29.19 0 26.56 0 21.64v-4.2C0 12.52.68 9.9 1.96 7.5A13.32 13.32 0 017.5 1.96C9.89.68 12.52 0 17.44 0z" clip-rule="evenodd"/>
536
+ <path fill="#AEB6CB" d="M19.95 10.58a.71.71 0 000 1.43.71.71 0 000-1.43zm-5.54 2.3a.71.71 0 000 1.43.71.71 0 000-1.43zm11.08 0a.71.71 0 000 1.43.71.71 0 000-1.43zm-5.63 1.27a4.98 4.98 0 00-2.05 9.48v1.2a2.14 2.14 0 004.28 0v-1.2a4.99 4.99 0 00-2.23-9.48zm-7.75 4.27a.71.71 0 000 1.43.71.71 0 000-1.43zm15.68 0a.71.71 0 000 1.43.71.71 0 000-1.43z"/>
537
+ </svg>
538
+ <span>Tips: Put your face inside the oval frame and click to "take selfie"</span> </small>
539
+
540
+ ${this.allowAgentMode ? `<button data-variant='outline small' id='switch-camera' class='button | center' type='button'>${this.inAgentMode ? 'Agent Mode On' : 'Agent Mode Off'}</button>` : ''}
541
+
542
+ <button data-variant='solid' id='start-image-capture' class='button | center' type='button'>
543
+ Take Selfie
544
+ </button>
545
+
546
+ ${this.hideAttribution ? '' : '<powered-by-smile-id></powered-by-smile-id>'}
547
+ </div>
548
+ </div>
549
+ `;
550
+ }
551
+
552
+ async function getPermissions(
553
+ captureScreen,
554
+ constraints = { facingMode: 'user' },
555
+ ) {
556
+ try {
557
+ const stream = await SmartCamera.getMedia({
558
+ audio: false,
559
+ video: constraints,
560
+ });
561
+ const devices = await navigator.mediaDevices.enumerateDevices();
562
+ const videoDevice = devices.find(
563
+ (device) =>
564
+ device.kind === 'videoinput' &&
565
+ stream.getVideoTracks()[0].getSettings().deviceId === device.deviceId,
566
+ );
567
+ window.dispatchEvent(
568
+ new CustomEvent('metadata.camera-name', {
569
+ detail: { cameraName: videoDevice?.label },
570
+ }),
571
+ );
572
+ captureScreen?.removeAttribute('data-camera-error');
573
+ captureScreen?.setAttribute('data-camera-ready', true);
574
+ } catch (error) {
575
+ captureScreen?.removeAttribute('data-camera-ready');
576
+ captureScreen?.setAttribute(
577
+ 'data-camera-error',
578
+ SmartCamera.handleCameraError(error),
579
+ );
580
+ }
581
+ }
582
+
583
+ class SelfieCaptureScreen extends HTMLElement {
584
+ constructor() {
585
+ super();
586
+ this.templateString = templateString.bind(this);
587
+ this.render = () => this.templateString();
588
+
589
+ this.attachShadow({ mode: 'open' });
590
+ this.facingMode = 'user';
591
+ if (this.allowAgentMode) {
592
+ this.facingMode = 'environment';
593
+ }
594
+ }
595
+
596
+ connectedCallback() {
597
+ const template = document.createElement('template');
598
+ template.innerHTML = this.render();
599
+ this.shadowRoot.innerHTML = '';
600
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
601
+ this.videoContainer = this.shadowRoot.querySelector(
602
+ '.video-container > .video',
603
+ );
604
+ this.init();
605
+ }
606
+
607
+ init() {
608
+ this._videoStreamDurationInMS = 7800;
609
+ this._imageCaptureIntervalInMS = 200;
610
+
611
+ this._data = {
612
+ images: [],
613
+ meta: {
614
+ libraryVersion: COMPONENTS_VERSION,
615
+ },
616
+ };
617
+ this._rawImages = [];
618
+
619
+ this.setUpEventListeners();
620
+ }
621
+
622
+ reset() {
623
+ this.disconnectedCallback();
624
+ this.connectedCallback();
625
+ }
626
+
627
+ _startImageCapture() {
628
+ this.startImageCapture.disabled = true;
629
+ if (this.switchCamera) {
630
+ this.switchCamera.disabled = true;
631
+ }
632
+
633
+ /**
634
+ * this was culled from https://jakearchibald.com/2013/animated-line-drawing-svg/
635
+ */
636
+ // NOTE: initialise image outline
637
+ const imageOutlineLength = this.imageOutline.getTotalLength();
638
+ // Clear any previous transition
639
+ this.imageOutline.style.transition = 'none';
640
+ // Set up the starting positions
641
+ this.imageOutline.style.strokeDasharray = `${imageOutlineLength} ${imageOutlineLength}`;
642
+ this.imageOutline.style.strokeDashoffset = imageOutlineLength;
643
+ // Trigger a layout so styles are calculated & the browser
644
+ // picks up the starting position before animating
645
+ this.imageOutline.getBoundingClientRect();
646
+ // Define our transition
647
+ this.imageOutline.style.transition = `stroke-dashoffset ${this._videoStreamDurationInMS / 1000}s ease-in-out`;
648
+ // Go!
649
+ this.imageOutline.style.strokeDashoffset = '0';
650
+
651
+ this.smileCTA.style.animation = `fadeInOut ease ${this._videoStreamDurationInMS / 1000}s`;
652
+
653
+ this._imageCaptureInterval = setInterval(() => {
654
+ this._capturePOLPhoto();
655
+ }, this._imageCaptureIntervalInMS);
656
+
657
+ this._videoStreamTimeout = setTimeout(() => {
658
+ this._stopVideoStream();
659
+ }, this._videoStreamDurationInMS);
660
+ }
661
+
662
+ async _switchCamera() {
663
+ this.facingMode = this.facingMode === 'user' ? 'environment' : 'user';
664
+ if (this.facingMode === 'user') {
665
+ this.shadowRoot.querySelector('video').classList.remove('agent-mode');
666
+ } else {
667
+ this.shadowRoot.querySelector('video').classList.add('agent-mode');
668
+ }
669
+ this.startImageCapture.disabled = true;
670
+ this.switchCamera.disabled = true;
671
+ SmartCamera.stopMedia();
672
+ await getPermissions(this, { facingMode: this.facingMode });
673
+ this.handleStream(SmartCamera.stream);
674
+ }
675
+
676
+ _stopVideoStream() {
677
+ try {
678
+ clearTimeout(this._videoStreamTimeout);
679
+ clearInterval(this._imageCaptureInterval);
680
+ clearInterval(this._drawingInterval);
681
+ this.smileCTA.style.animation = 'none';
682
+
683
+ this._capturePOLPhoto(); // NOTE: capture the last photo
684
+ this._captureReferencePhoto();
685
+ SmartCamera.stopMedia();
686
+
687
+ const totalNoOfFrames = this._rawImages.length;
688
+ this._data.referenceImage = this._referenceImage;
689
+ this._data.previewImage = this._referenceImage;
690
+
691
+ const livenessFramesIndices = getLivenessFramesIndices(totalNoOfFrames);
692
+
693
+ this._data.images = this._data.images.concat(
694
+ livenessFramesIndices.map((imageIndex) => ({
695
+ image: this._rawImages[imageIndex].split(',')[1],
696
+ image_type_id: IMAGE_TYPE.LIVENESS_IMAGE_BASE64,
697
+ })),
698
+ );
699
+
700
+ this._publishImages();
701
+ } catch (error) {
702
+ console.error(error);
703
+ // Todo: handle error
704
+ }
705
+ }
706
+
707
+ _capturePOLPhoto() {
708
+ const canvas = document.createElement('canvas');
709
+ // Determine orientation of the video
710
+ const isPortrait = this._video.videoHeight > this._video.videoWidth;
711
+
712
+ // Set dimensions based on orientation, ensuring minimums
713
+ if (isPortrait) {
714
+ // Portrait orientation (taller than wide)
715
+ canvas.width = 240;
716
+ canvas.height = Math.max(
717
+ 320,
718
+ (canvas.width * this._video.videoHeight) / this._video.videoWidth,
719
+ );
720
+ } else {
721
+ // Landscape orientation (wider than tall)
722
+ canvas.height = 240;
723
+ canvas.width = Math.max(
724
+ 320,
725
+ (canvas.height * this._video.videoWidth) / this._video.videoHeight,
726
+ );
727
+ }
728
+
729
+ // NOTE: we do not want to test POL images
730
+ this._drawImage(canvas, false);
731
+
732
+ this._rawImages.push(canvas.toDataURL('image/jpeg'));
733
+ }
734
+
735
+ _captureReferencePhoto() {
736
+ const canvas = document.createElement('canvas');
737
+ // Determine orientation of the video
738
+ const isPortrait = this._video.videoHeight > this._video.videoWidth;
739
+
740
+ // Set dimensions based on orientation, ensuring minimums
741
+ if (isPortrait) {
742
+ // Portrait orientation (taller than wide)
743
+ canvas.width = 480;
744
+ canvas.height = Math.max(
745
+ 640,
746
+ (canvas.width * this._video.videoHeight) / this._video.videoWidth,
747
+ );
748
+ } else {
749
+ // Landscape orientation (wider than tall)
750
+ canvas.height = 480;
751
+ canvas.width = Math.max(
752
+ 640,
753
+ (canvas.height * this._video.videoWidth) / this._video.videoHeight,
754
+ );
755
+ }
756
+
757
+ // NOTE: we want to test the image quality of the reference photo
758
+ this._drawImage(canvas, !this.disableImageTests);
759
+
760
+ const image = canvas.toDataURL('image/jpeg');
761
+
762
+ this._referenceImage = image;
763
+
764
+ this._data.images.push({
765
+ image: image.split(',')[1],
766
+ image_type_id: IMAGE_TYPE.SELFIE_IMAGE_BASE64,
767
+ });
768
+ }
769
+
770
+ _publishImages() {
771
+ this.dispatchEvent(
772
+ new CustomEvent('selfie-capture.publish', {
773
+ detail: this._data,
774
+ }),
775
+ );
776
+ }
777
+
778
+ resetErrorMessage() {
779
+ this.errorMessage.textContent = '';
780
+ }
781
+
782
+ _drawImage(canvas, enableImageTests = true, video = this._video) {
783
+ // this.resetErrorMessage();
784
+ const context = canvas.getContext('2d');
785
+
786
+ context.drawImage(
787
+ video,
788
+ 0,
789
+ 0,
790
+ video.videoWidth,
791
+ video.videoHeight,
792
+ 0,
793
+ 0,
794
+ canvas.width,
795
+ canvas.height,
796
+ );
797
+
798
+ if (enableImageTests) {
799
+ const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
800
+
801
+ const hasEnoughColors = hasMoreThanNColors(imageData.data);
802
+
803
+ if (hasEnoughColors) {
804
+ return context;
805
+ }
806
+ throw new Error(
807
+ 'Unable to capture webcam images - Please try another device',
808
+ );
809
+ } else {
810
+ return context;
811
+ }
812
+ }
813
+
814
+ handleStream(stream) {
815
+ try {
816
+ const videoExists = this.shadowRoot.querySelector('video');
817
+ let video = null;
818
+ if (videoExists) {
819
+ video = this.shadowRoot.querySelector('video');
820
+ } else {
821
+ video = document.createElement('video');
822
+ }
823
+
824
+ video.autoplay = true;
825
+ video.playsInline = true;
826
+ video.muted = true;
827
+
828
+ if ('srcObject' in video) {
829
+ video.srcObject = stream;
830
+ } else {
831
+ video.src = window.URL.createObjectURL(stream);
832
+ }
833
+
834
+ video.onloadedmetadata = () => {
835
+ video.play();
836
+ };
837
+
838
+ this._video = video;
839
+ const videoContainer = this.shadowRoot.querySelector(
840
+ '.video-container > .video',
841
+ );
842
+ this._data.permissionGranted = true;
843
+
844
+ if (!videoExists) {
845
+ videoContainer.prepend(video);
846
+ }
847
+ } catch (error) {
848
+ this.setAttribute(
849
+ 'data-camera-error',
850
+ SmartCamera.handleCameraError(error),
851
+ );
852
+ if (error.name !== 'AbortError') {
853
+ console.error(error);
854
+ }
855
+ SmartCamera.stopMedia();
856
+ }
857
+ }
858
+
859
+ setUpEventListeners() {
860
+ this.navigation = this.shadowRoot.querySelector('smileid-navigation');
861
+
862
+ this.startImageCapture = this.shadowRoot.querySelector(
863
+ '#start-image-capture',
864
+ );
865
+
866
+ this.switchCamera = this.shadowRoot.querySelector('#switch-camera');
867
+ this.imageOutline = this.shadowRoot.querySelector('#image-outline path');
868
+ this.smileCTA = this.shadowRoot.querySelector('#smile-cta');
869
+
870
+ this.startImageCapture.addEventListener('click', () => {
871
+ this._startImageCapture();
872
+ });
873
+
874
+ this.switchCamera?.addEventListener('click', () => {
875
+ this._switchCamera();
876
+ });
877
+
878
+ this.navigation.addEventListener('navigation.back', () => {
879
+ this.handleBackEvents();
880
+ });
881
+
882
+ this.navigation.addEventListener('navigation.close', () => {
883
+ this.closeWindow();
884
+ });
885
+
886
+ if (SmartCamera.stream) {
887
+ this.handleStream(SmartCamera.stream);
888
+ } else if (this.hasAttribute('data-camera-ready')) {
889
+ getPermissions(this, { facingMode: this.facingMode });
890
+ }
891
+
892
+ this.setupAgentMode();
893
+ }
894
+
895
+ disconnectedCallback() {
896
+ SmartCamera.stopMedia();
897
+ clearTimeout(this._videoStreamTimeout);
898
+ }
899
+
900
+ get hideBack() {
901
+ return this.hasAttribute('hide-back');
902
+ }
903
+
904
+ get showNavigation() {
905
+ return this.hasAttribute('show-navigation');
906
+ }
907
+
908
+ get themeColor() {
909
+ return this.getAttribute('theme-color') || '#001096';
910
+ }
911
+
912
+ get hideAttribution() {
913
+ return this.hasAttribute('hide-attribution');
914
+ }
915
+
916
+ async setupAgentMode() {
917
+ if (!this.allowAgentMode) {
918
+ return;
919
+ }
920
+
921
+ const supportAgentMode = await SmartCamera.supportsAgentMode();
922
+
923
+ if (supportAgentMode || this.hasAttribute('show-agent-mode-for-tests')) {
924
+ this.switchCamera.hidden = false;
925
+ if (this.facingMode === 'user') {
926
+ this.shadowRoot.querySelector('video')?.classList?.remove('agent-mode');
927
+ } else {
928
+ this.shadowRoot.querySelector('video')?.classList?.add('agent-mode');
929
+ }
930
+ } else {
931
+ this.switchCamera.hidden = true;
932
+ }
933
+ }
934
+
935
+ get hasAgentSupport() {
936
+ return this.hasAttribute('has-agent-support');
937
+ }
938
+
939
+ get title() {
940
+ return this.getAttribute('title') || 'Submit Front of ID';
941
+ }
942
+
943
+ get hidden() {
944
+ return this.getAttribute('hidden');
945
+ }
946
+
947
+ get cameraError() {
948
+ return this.getAttribute('data-camera-error');
949
+ }
950
+
951
+ get disableImageTests() {
952
+ return this.hasAttribute('disable-image-tests');
953
+ }
954
+
955
+ get allowAgentMode() {
956
+ return this.getAttribute('allow-agent-mode') === 'true';
957
+ }
958
+
959
+ get inAgentMode() {
960
+ return this.facingMode === 'environment';
961
+ }
962
+
963
+ static get observedAttributes() {
964
+ return [
965
+ 'allow-agent-mode',
966
+ 'data-camera-error',
967
+ 'data-camera-ready',
968
+ 'disable-image-tests',
969
+ 'hidden',
970
+ 'hide-back-to-host',
971
+ 'show-navigation',
972
+ 'title',
973
+ ];
974
+ }
975
+
976
+ attributeChangedCallback(name) {
977
+ switch (name) {
978
+ case 'data-camera-error':
979
+ case 'data-camera-ready':
980
+ case 'hidden':
981
+ case 'title':
982
+ case 'allow-agent-mode':
983
+ this.shadowRoot.innerHTML = this.render();
984
+ this.init();
985
+ break;
986
+ default:
987
+ break;
988
+ }
989
+ }
990
+
991
+ handleBackEvents() {
992
+ this.stopMedia();
993
+ this.dispatchEvent(new CustomEvent('selfie-capture.cancelled'));
994
+ }
995
+
996
+ closeWindow() {
997
+ this.stopMedia();
998
+ this.dispatchEvent(new CustomEvent('selfie-capture.close'));
999
+ }
1000
+
1001
+ stopMedia() {
1002
+ this.removeAttribute('data-camera-ready');
1003
+ SmartCamera.stopMedia();
1004
+ }
1005
+ }
1006
+
1007
+ if ('customElements' in window && !customElements.get('selfie-capture')) {
1008
+ window.customElements.define('selfie-capture', SelfieCaptureScreen);
1009
+ }
1010
+
1011
+ export default SelfieCaptureScreen;