UNPKG

biometry-angular-components

Version:

Angular UI component library for capturing biometric data

698 lines (690 loc) 72.6 kB
import * as i0 from '@angular/core'; import { Injectable, EventEmitter, Component, ChangeDetectionStrategy, Input, Output, ViewChild } from '@angular/core'; import * as i3 from '@angular/common'; import { CommonModule } from '@angular/common'; class PermissionsService { async requestCamera(options) { const { width = 640, height = 480, audio = false } = options; try { const stream = await navigator.mediaDevices.getUserMedia({ video: { width, height }, audio }); return { stream, granted: true }; } catch { return { granted: false }; } } async requestMicrophone() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); return { stream, granted: true }; } catch { return { granted: false }; } } stopStream(stream) { if (!stream) return; stream.getTracks().forEach(track => track.stop()); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); class DocScanComponent { perms; zone; cdr; rectWidth = 640; rectHeight = 400; noShadow = false; capture = new EventEmitter(); isConfirming = false; capturedUrl = null; capturedFile = null; stream; QUALITY_MULTIPLIER = 3; constructor(perms, zone, cdr) { this.perms = perms; this.zone = zone; this.cdr = cdr; } ngOnInit() { this.initStream(); } ngOnDestroy() { this.stopStream(); this.revokeBlob(); } async initStream() { const { stream, granted } = await this.perms.requestCamera({ width: this.rectWidth, height: this.rectHeight, }); if (!granted || !stream) return; this.zone.run(() => { this.stream = stream; this.cdr.markForCheck(); }); } stopStream() { if (!this.stream) return; this.perms.stopStream(this.stream); this.stream = undefined; } handleCapture(videoEl) { if (!videoEl) return; const canvas = document.createElement('canvas'); canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER; canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER; const ctx = canvas.getContext('2d'); if (!ctx) return; const videoAspect = videoEl.videoWidth / videoEl.videoHeight; const targetAspect = this.rectWidth / this.rectHeight; let sx = 0, sy = 0, sw = videoEl.videoWidth, sh = videoEl.videoHeight; if (videoAspect > targetAspect) { sw = videoEl.videoHeight * targetAspect; sx = (videoEl.videoWidth - sw) / 2; } else { sh = videoEl.videoWidth / targetAspect; sy = (videoEl.videoHeight - sh) / 2; } ctx.drawImage(videoEl, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => { if (!blob) return; const file = new File([blob], 'document.jpg', { type: 'image/jpeg' }); this.zone.run(() => { this.capturedFile = file; this.capturedUrl = URL.createObjectURL(blob); this.isConfirming = true; this.cdr.markForCheck(); }); }, 'image/jpeg', 0.98); } handleConfirm() { if (!this.capturedFile) return; this.capture.emit(this.capturedFile); } handleDecline() { this.revokeBlob(); this.capturedFile = null; this.isConfirming = false; this.initStream(); } revokeBlob() { if (!this.capturedUrl) return; URL.revokeObjectURL(this.capturedUrl); this.capturedUrl = null; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DocScanComponent, deps: [{ token: PermissionsService }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: DocScanComponent, isStandalone: true, selector: "bio-doc-scan", inputs: { rectWidth: "rectWidth", rectHeight: "rectHeight", noShadow: "noShadow" }, outputs: { capture: "capture" }, ngImport: i0, template: "<div class=\"docscan-wrapper\"\n [ngStyle]=\"{ width: rectWidth + 'px', height: rectHeight + 64 + 'px', boxShadow: noShadow ? 'none' : '0 4px 24px rgba(0,0,0,0.12)' }\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"docscan-camera-area\">\n <video #video [width]=\"rectWidth\" [height]=\"rectHeight\" autoplay muted playsinline\n [style.border-radius.px]=\"noShadow ? 0 : 8\" class=\"docscan-video\" [srcObject]=\"stream\">\n </video>\n <div class=\"docscan-dark-overlay\"></div>\n <div class=\"docscan-dashed-area\">\n <span class=\"docscan-guidance-text\">Place your document here</span>\n </div>\n </div>\n <button class=\"docscan-capture-btn\" (click)=\"handleCapture(video)\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"docscan-preview\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured document\" />\n </div>\n <div class=\"docscan-confirm-text\">Do you want to use this photo?</div>\n <div class=\"docscan-confirm-btns\">\n <button class=\"docscan-icon-btn\" (click)=\"handleDecline()\">\n <!-- SVG Retake -->\n </button>\n <button class=\"docscan-icon-btn\" (click)=\"handleConfirm()\">\n <!-- SVG Confirm -->\n </button>\n </div>\n </ng-template>\n</div>", styles: [".docscan-wrapper{background:#fff;border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0 auto}.docscan-wrapper .docscan-camera-area{position:relative;width:100%;height:100%;margin-bottom:20px}.docscan-wrapper .docscan-camera-area video{object-fit:cover;position:absolute;top:0;left:0;z-index:0;width:100%;height:100%}.docscan-wrapper .docscan-camera-area .docscan-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;border-radius:8px;z-index:1}.docscan-wrapper .docscan-camera-area .docscan-dashed-area{position:absolute;top:10%;left:10%;width:80%;height:80%;border:2.5px dashed #fff;border-radius:16px;z-index:2;display:flex;align-items:center;justify-content:center}.docscan-wrapper .docscan-camera-area .docscan-dashed-area .docscan-guidance-text{color:#fff;font-size:12px;text-shadow:0 1px 4px rgba(0,0,0,.25)}.docscan-wrapper .docscan-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a}.docscan-wrapper .docscan-preview img{max-width:100%;max-height:100%;border-radius:8px}.docscan-wrapper .docscan-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px}.docscan-wrapper .docscan-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DocScanComponent, decorators: [{ type: Component, args: [{ selector: 'bio-doc-scan', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"docscan-wrapper\"\n [ngStyle]=\"{ width: rectWidth + 'px', height: rectHeight + 64 + 'px', boxShadow: noShadow ? 'none' : '0 4px 24px rgba(0,0,0,0.12)' }\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"docscan-camera-area\">\n <video #video [width]=\"rectWidth\" [height]=\"rectHeight\" autoplay muted playsinline\n [style.border-radius.px]=\"noShadow ? 0 : 8\" class=\"docscan-video\" [srcObject]=\"stream\">\n </video>\n <div class=\"docscan-dark-overlay\"></div>\n <div class=\"docscan-dashed-area\">\n <span class=\"docscan-guidance-text\">Place your document here</span>\n </div>\n </div>\n <button class=\"docscan-capture-btn\" (click)=\"handleCapture(video)\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"docscan-preview\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured document\" />\n </div>\n <div class=\"docscan-confirm-text\">Do you want to use this photo?</div>\n <div class=\"docscan-confirm-btns\">\n <button class=\"docscan-icon-btn\" (click)=\"handleDecline()\">\n <!-- SVG Retake -->\n </button>\n <button class=\"docscan-icon-btn\" (click)=\"handleConfirm()\">\n <!-- SVG Confirm -->\n </button>\n </div>\n </ng-template>\n</div>", styles: [".docscan-wrapper{background:#fff;border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0 auto}.docscan-wrapper .docscan-camera-area{position:relative;width:100%;height:100%;margin-bottom:20px}.docscan-wrapper .docscan-camera-area video{object-fit:cover;position:absolute;top:0;left:0;z-index:0;width:100%;height:100%}.docscan-wrapper .docscan-camera-area .docscan-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;border-radius:8px;z-index:1}.docscan-wrapper .docscan-camera-area .docscan-dashed-area{position:absolute;top:10%;left:10%;width:80%;height:80%;border:2.5px dashed #fff;border-radius:16px;z-index:2;display:flex;align-items:center;justify-content:center}.docscan-wrapper .docscan-camera-area .docscan-dashed-area .docscan-guidance-text{color:#fff;font-size:12px;text-shadow:0 1px 4px rgba(0,0,0,.25)}.docscan-wrapper .docscan-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a}.docscan-wrapper .docscan-preview img{max-width:100%;max-height:100%;border-radius:8px}.docscan-wrapper .docscan-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px}.docscan-wrapper .docscan-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%}\n"] }] }], ctorParameters: () => [{ type: PermissionsService }, { type: i0.NgZone }, { type: i0.ChangeDetectorRef }], propDecorators: { rectWidth: [{ type: Input }], rectHeight: [{ type: Input }], noShadow: [{ type: Input }], capture: [{ type: Output }] } }); class FaceCaptureComponent { perms; ngZone; cdr; rectWidth = 360; rectHeight = 576; noShadow = false; capture = new EventEmitter(); videoEl; isConfirming = false; capturedUrl = null; capturedFile = null; stream; QUALITY_MULTIPLIER = 3; constructor(perms, ngZone, cdr) { this.perms = perms; this.ngZone = ngZone; this.cdr = cdr; } async ngOnInit() { await this.initStream(); } ngOnDestroy() { this.stopStream(); if (this.capturedUrl) URL.revokeObjectURL(this.capturedUrl); } async initStream() { try { const { stream, granted } = await this.perms.requestCamera({ width: this.rectWidth, height: this.rectHeight, }); if (!granted) { return; } this.stream = stream; console.log('this.stream = stream'); this.ngZone.runOutsideAngular(() => { if (this.videoEl?.nativeElement) { console.log('nativeElement'); this.videoEl.nativeElement.srcObject = this.stream; this.videoEl.nativeElement.onloadedmetadata = () => this.videoEl.nativeElement.play(); } }); } catch (err) { console.error('Camera access error:', err); } } stopStream() { this.stream?.getTracks().forEach(track => track.stop()); this.stream = undefined; } handleCapture() { const video = this.videoEl?.nativeElement; if (!video) return; const canvas = document.createElement('canvas'); canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER; canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(video, 0, 0, this.rectWidth, this.rectHeight, 0, 0, this.rectWidth * this.QUALITY_MULTIPLIER, this.rectHeight * this.QUALITY_MULTIPLIER); canvas.toBlob(blob => { if (blob) { const file = new File([blob], 'face.jpg', { type: 'image/jpeg' }); this.capturedFile = file; this.capturedUrl = URL.createObjectURL(blob); this.isConfirming = true; this.stopStream(); this.cdr.markForCheck(); } }, 'image/jpeg', 0.98); } } handleConfirm() { if (this.capturedFile) { this.capture.emit(this.capturedFile); } } handleDecline() { if (this.capturedUrl) { URL.revokeObjectURL(this.capturedUrl); } this.capturedUrl = null; this.capturedFile = null; this.isConfirming = false; this.initStream(); this.cdr.markForCheck(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceCaptureComponent, deps: [{ token: PermissionsService }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FaceCaptureComponent, isStandalone: true, selector: "bio-face-capture", inputs: { rectWidth: "rectWidth", rectHeight: "rectHeight", noShadow: "noShadow" }, outputs: { capture: "capture" }, viewQueries: [{ propertyName: "videoEl", first: true, predicate: ["videoEl"], descendants: true }], ngImport: i0, template: "<div class=\"facecapture-wrapper\" [ngClass]=\"{ 'no-shadow': noShadow }\" [style.width.px]=\"rectWidth\"\n [style.height.px]=\"rectHeight + 64\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"facecapture-camera-area\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\">\n <video #videoEl class=\"facecapture-video\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\"\n playsinline muted autoplay></video>\n <div class=\"facecapture-dark-overlay\" [ngClass]=\"{ 'no-shadow': noShadow }\"></div>\n <div class=\"facecapture-oval-area\">\n <span class=\"facecapture-guidance-text\">Place your face in the oval</span>\n <div class=\"facecapture-oval-dashed\"></div>\n </div>\n </div>\n <button type=\"button\" class=\"facecapture-capture-btn\" (click)=\"handleCapture()\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"facecapture-preview\" [style.width.px]=\"rectWidth - 25\" [style.height.px]=\"rectHeight - 25\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured face\" class=\"facecapture-preview-img\" />\n </div>\n <div class=\"facecapture-confirm-text\">Do you want to use this photo?</div>\n <div class=\"facecapture-confirm-btns\">\n <button type=\"button\" aria-label=\"Retake\" class=\"facecapture-icon-btn decline\" (click)=\"handleDecline()\">\n \u2716\n </button>\n <button type=\"button\" aria-label=\"Confirm\" class=\"facecapture-icon-btn confirm\" (click)=\"handleConfirm()\">\n \u2714\n </button>\n </div>\n </ng-template>\n</div>", styles: [".facecapture-wrapper{background:#fff;border-radius:10px;box-shadow:0 4px 24px #0000001f;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center}.facecapture-wrapper.no-shadow{border-radius:0;box-shadow:none}.facecapture-camera-area{position:relative;margin-bottom:20px;display:flex;align-items:center;justify-content:center}.facecapture-video{object-fit:cover;border-radius:8px;z-index:0}.facecapture-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;z-index:1;border-radius:8px}.facecapture-dark-overlay.no-shadow{border-radius:0}.facecapture-oval-area{position:absolute;top:18%;left:50%;transform:translate(-50%);width:72%;height:68%;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;pointer-events:none}.facecapture-guidance-text{color:#fff;font-size:12px;font-weight:400;margin-bottom:4px;letter-spacing:.2px;text-shadow:0 1px 4px rgba(0,0,0,.25);-webkit-user-select:none;user-select:none}.facecapture-oval-dashed{width:100%;height:100%;border:2.5px dashed #fff;border-radius:50%;box-sizing:border-box;background:transparent}.facecapture-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a;transition:background .2s}.facecapture-capture-btn:hover{background:#125a9e}.facecapture-preview{display:flex;align-items:center;justify-content:center;background:#111;border-radius:8px;margin:0 auto}.facecapture-preview .facecapture-preview-img{max-width:100%;max-height:100%;border-radius:8px}.facecapture-confirm-text{text-align:center;margin-top:16px;color:#111;font-size:17px;font-weight:500;text-shadow:0 1px 4px rgba(255,255,255,.12)}.facecapture-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px;width:100%}.facecapture-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%;transition:background .2s}.facecapture-icon-btn.decline:hover{background:#d32f2f1f}.facecapture-icon-btn.confirm:hover{background:#388e3c1f}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceCaptureComponent, decorators: [{ type: Component, args: [{ selector: 'bio-face-capture', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"facecapture-wrapper\" [ngClass]=\"{ 'no-shadow': noShadow }\" [style.width.px]=\"rectWidth\"\n [style.height.px]=\"rectHeight + 64\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"facecapture-camera-area\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\">\n <video #videoEl class=\"facecapture-video\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\"\n playsinline muted autoplay></video>\n <div class=\"facecapture-dark-overlay\" [ngClass]=\"{ 'no-shadow': noShadow }\"></div>\n <div class=\"facecapture-oval-area\">\n <span class=\"facecapture-guidance-text\">Place your face in the oval</span>\n <div class=\"facecapture-oval-dashed\"></div>\n </div>\n </div>\n <button type=\"button\" class=\"facecapture-capture-btn\" (click)=\"handleCapture()\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"facecapture-preview\" [style.width.px]=\"rectWidth - 25\" [style.height.px]=\"rectHeight - 25\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured face\" class=\"facecapture-preview-img\" />\n </div>\n <div class=\"facecapture-confirm-text\">Do you want to use this photo?</div>\n <div class=\"facecapture-confirm-btns\">\n <button type=\"button\" aria-label=\"Retake\" class=\"facecapture-icon-btn decline\" (click)=\"handleDecline()\">\n \u2716\n </button>\n <button type=\"button\" aria-label=\"Confirm\" class=\"facecapture-icon-btn confirm\" (click)=\"handleConfirm()\">\n \u2714\n </button>\n </div>\n </ng-template>\n</div>", styles: [".facecapture-wrapper{background:#fff;border-radius:10px;box-shadow:0 4px 24px #0000001f;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center}.facecapture-wrapper.no-shadow{border-radius:0;box-shadow:none}.facecapture-camera-area{position:relative;margin-bottom:20px;display:flex;align-items:center;justify-content:center}.facecapture-video{object-fit:cover;border-radius:8px;z-index:0}.facecapture-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;z-index:1;border-radius:8px}.facecapture-dark-overlay.no-shadow{border-radius:0}.facecapture-oval-area{position:absolute;top:18%;left:50%;transform:translate(-50%);width:72%;height:68%;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;pointer-events:none}.facecapture-guidance-text{color:#fff;font-size:12px;font-weight:400;margin-bottom:4px;letter-spacing:.2px;text-shadow:0 1px 4px rgba(0,0,0,.25);-webkit-user-select:none;user-select:none}.facecapture-oval-dashed{width:100%;height:100%;border:2.5px dashed #fff;border-radius:50%;box-sizing:border-box;background:transparent}.facecapture-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a;transition:background .2s}.facecapture-capture-btn:hover{background:#125a9e}.facecapture-preview{display:flex;align-items:center;justify-content:center;background:#111;border-radius:8px;margin:0 auto}.facecapture-preview .facecapture-preview-img{max-width:100%;max-height:100%;border-radius:8px}.facecapture-confirm-text{text-align:center;margin-top:16px;color:#111;font-size:17px;font-weight:500;text-shadow:0 1px 4px rgba(255,255,255,.12)}.facecapture-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px;width:100%}.facecapture-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%;transition:background .2s}.facecapture-icon-btn.decline:hover{background:#d32f2f1f}.facecapture-icon-btn.confirm:hover{background:#388e3c1f}\n"] }] }], ctorParameters: () => [{ type: PermissionsService }, { type: i0.NgZone }, { type: i0.ChangeDetectorRef }], propDecorators: { rectWidth: [{ type: Input }], rectHeight: [{ type: Input }], noShadow: [{ type: Input }], capture: [{ type: Output }], videoEl: [{ type: ViewChild, args: ['videoEl', { static: false }] }] } }); class RecorderService { chunks = []; mediaRecorder; start(stream, mimeType = 'video/webm') { this.chunks = []; this.mediaRecorder = new MediaRecorder(stream, { mimeType }); this.mediaRecorder.ondataavailable = e => { if (e.data.size) this.chunks.push(e.data); }; this.mediaRecorder.start(); } stop() { return new Promise(resolve => { if (!this.mediaRecorder) return resolve(new Blob()); this.mediaRecorder.onstop = () => resolve(new Blob(this.chunks, { type: this.mediaRecorder?.mimeType })); this.mediaRecorder.stop(); }); } cancel() { if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { this.mediaRecorder.stop(); } this.chunks = []; this.mediaRecorder = undefined; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); function numbersToPhrase$1(digits) { const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; return digits.map(d => words[d]).join(' '); } function pickSupportedMimeType() { const candidates = [ 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4;codecs=h264,aac', 'video/mp4', ]; for (const type of candidates) { // @ts-ignore - isTypeSupported exists in browsers that implement MediaRecorder if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(type)) { return type; } } return 'video/webm'; } class FaceRecorderComponent { permissions; recorder; cdr; videoRef; countdownSeconds = 3; recordingSeconds = 10; /** Prefer vertical 9:16; request 720x1280 from camera */ videoWidth = 720; videoHeight = 1280; capture = new EventEmitter(); state = 'preparation'; digits = []; maskedDigits = []; countdownLeft = 0; recordLeft = 0; countdownTimer; recordTimer; stream; videoBlob = null; videoUrl = null; isConfirming = false; permissionsGranted = null; mimeType = pickSupportedMimeType(); constructor(permissions, recorder, cdr) { this.permissions = permissions; this.recorder = recorder; this.cdr = cdr; } ngOnInit() { this.generateDigits(); this.requestPermissions(); } ngAfterViewInit() { if (this.stream) { this.attachStream(); } } ngOnDestroy() { this.clearAllTimers(); this.cleanupPlayback(); this.stopTracks(); this.recorder.cancel(); } /** === Flow control === */ async requestPermissions() { this.permissionsGranted = null; this.cdr.markForCheck(); const { stream, granted } = await this.permissions.requestCamera({ width: this.videoWidth, height: this.videoHeight, audio: true, }); this.permissionsGranted = granted; if (granted && stream) { this.stream = stream; this.attachStream(); } else { this.stream = undefined; } this.cdr.markForCheck(); } attachStream() { const video = this.videoRef.nativeElement; video.srcObject = this.stream ?? null; video.src = ''; video.controls = false; video.muted = true; video.play().catch(() => { }); } startFlow() { if (!this.stream || this.state !== 'preparation') return; this.startCountdown(); } startCountdown() { this.state = 'countdown'; this.countdownLeft = this.countdownSeconds; this.cdr.markForCheck(); this.clearCountdown(); this.countdownTimer = setInterval(() => { this.countdownLeft -= 1; this.cdr.markForCheck(); if (this.countdownLeft <= 0) { this.clearCountdown(); this.startRecording(); } }, 1000); } startRecording() { if (!this.stream) return; // Keep preview running; MediaRecorder records from the same stream this.state = 'recording'; this.recordLeft = this.recordingSeconds; try { this.recorder.start(this.stream, this.mimeType); } catch (e) { console.error('Failed to start recorder', e); this.cancelRecording(); return; } this.clearRecordTimer(); this.recordTimer = setInterval(async () => { this.recordLeft -= 1; this.cdr.markForCheck(); if (this.recordLeft <= 0) { await this.finishRecording(); } }, 1000); this.cdr.markForCheck(); } async finishRecording() { if (this.state !== 'recording') return; this.state = 'processing'; this.clearRecordTimer(); this.cdr.markForCheck(); try { this.videoBlob = await this.recorder.stop(); } catch { this.videoBlob = null; } if (this.videoBlob && this.videoBlob.size) { if (this.videoUrl) URL.revokeObjectURL(this.videoUrl); this.videoUrl = URL.createObjectURL(this.videoBlob); // Switch the element from live preview to the recorded video const video = this.videoRef.nativeElement; video.srcObject = null; video.src = this.videoUrl; video.controls = true; video.muted = false; await video.play().catch(() => { }); this.state = 'result'; } else { // No data recorded: return to preparation with live preview this.state = 'preparation'; this.attachStream(); } this.cdr.markForCheck(); } cancelRecording() { if (this.state !== 'countdown' && this.state !== 'recording') return; this.clearCountdown(); this.clearRecordTimer(); this.recorder.cancel(); if (this.videoUrl) URL.revokeObjectURL(this.videoUrl); this.videoUrl = null; this.videoBlob = null; // Return to preparation and keep preview alive this.state = 'preparation'; this.attachStream(); this.cdr.markForCheck(); } handleDecline() { if (this.videoUrl) URL.revokeObjectURL(this.videoUrl); this.videoUrl = null; this.videoBlob = null; this.state = 'preparation'; this.generateDigits(); this.requestPermissions(); this.cdr.markForCheck(); } handleConfirm() { if (!this.videoBlob) return; const file = new File([this.videoBlob], 'face-video.webm', { type: this.videoBlob.type, }); this.capture.emit({ file, phrase: numbersToPhrase$1(this.digits) }); if (this.videoUrl) URL.revokeObjectURL(this.videoUrl); this.videoUrl = null; this.videoBlob = null; this.isConfirming = false; this.state = 'preparation'; this.requestPermissions(); this.cdr.markForCheck(); } /** === Helpers === */ generateDigits() { this.digits = Array.from({ length: 10 }, () => Math.floor(Math.random() * 10)); this.maskedDigits = Array(this.digits.length).fill('*'); } clearCountdown() { if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } } clearRecordTimer() { if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null; } } clearAllTimers() { this.clearCountdown(); this.clearRecordTimer(); } cleanupPlayback() { const v = this.videoRef?.nativeElement; if (v) { v.pause(); v.srcObject = null; v.src = ''; v.removeAttribute('src'); } if (this.videoUrl) URL.revokeObjectURL(this.videoUrl); this.videoUrl = null; this.videoBlob = null; } stopTracks() { this.stream?.getTracks().forEach(t => t.stop()); this.stream = undefined; } get displayDigits() { return (this.state === 'recording' ? this.digits : this.maskedDigits).map(d => d.toString()); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceRecorderComponent, deps: [{ token: PermissionsService }, { token: RecorderService }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FaceRecorderComponent, isStandalone: true, selector: "bio-face-recorder", inputs: { countdownSeconds: "countdownSeconds", recordingSeconds: "recordingSeconds", videoWidth: "videoWidth", videoHeight: "videoHeight" }, outputs: { capture: "capture" }, viewQueries: [{ propertyName: "videoRef", first: true, predicate: ["videoEl"], descendants: true, static: true }], ngImport: i0, template: "<div class=\"recorder-wrapper\" [class.result]=\"state === 'result'\">\n <!-- Video frame (9:16) -->\n <div class=\"frame\">\n <video #videoEl class=\"preview\" autoplay playsinline muted preload=\"metadata\"></video>\n\n <!-- Oval dashed overlay -->\n <div class=\"oval-area\" *ngIf=\"state !== 'result' && state !== 'processing'\">\n <span class=\"guidance-text\">Place your face in the oval</span>\n <div class=\"oval-dashed\"></div>\n </div>\n\n <!-- HUD: digits to read -->\n <!-- <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div> -->\n\n <!-- HUD: countdown -->\n <div class=\"overlay center\" *ngIf=\"state === 'countdown'\">\n <div class=\"countdown\">{{ countdownLeft }}</div>\n <button class=\"btn ghost\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: recording timer + cancel -->\n <div class=\"overlay top\" *ngIf=\"state === 'recording'\">\n <div class=\"timer\">\n <span class=\"dot\"></span>\n <span>{{ recordLeft }}s</span>\n </div>\n <button class=\"btn danger\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: processing -->\n <div class=\"overlay center\" *ngIf=\"state === 'processing'\">\n <div class=\"spinner\"></div>\n <div class=\"processing-label\">Processing\u2026</div>\n </div>\n </div>\n\n <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div>\n\n <!-- Controls / CTA -->\n <div class=\"controls\">\n <!-- Preparation -->\n <ng-container *ngIf=\"state === 'preparation'\">\n <div class=\"hint\" *ngIf=\"permissionsGranted === false\">\n Camera access denied. Please allow camera & audio and try again.\n </div>\n <button class=\"btn primary\" type=\"button\" (click)=\"startFlow()\" [disabled]=\"!permissionsGranted\">\n Start recording\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"requestPermissions()\">Retry camera</button>\n </ng-container>\n\n <!-- Result -->\n <ng-container *ngIf=\"state === 'result'\">\n <div class=\"result-actions\">\n <button class=\"btn success\" type=\"button\" [disabled]=\"isConfirming\" (click)=\"handleConfirm()\">\n {{ isConfirming ? 'Confirming\u2026' : 'Use this video' }}\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"handleDecline()\">Retake</button>\n </div>\n </ng-container>\n </div>\n</div>", styles: [".recorder-wrapper{display:grid;gap:12px;justify-items:center;width:100%}.frame{position:relative;width:100%;max-width:320px;aspect-ratio:9/16;background:#000;border-radius:20px;overflow:hidden;box-shadow:0 6px 20px #00000040}.preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.oval-area{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}.guidance-text{position:absolute;top:13%;left:50%;transform:translate(-50%);text-wrap:nowrap;color:#fff;background:#00000059;padding:6px 10px;text-align:center;border-radius:30px;font-size:12px;letter-spacing:.2px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.oval-dashed{width:70%;height:58%;border:3px dashed rgba(255,255,255,.8);border-radius:50%;box-shadow:0 0 0 9999px #00000026 inset}.digits{width:min(100%,420px);display:grid;gap:8px;justify-items:center;margin-top:4px}.digits-label{font-size:13px;opacity:.8;text-align:center}.digits-row{display:flex;justify-content:center;gap:8px;flex-wrap:nowrap;overflow-x:auto}.digit-box{width:28px;height:36px;background:#f5f5f5;border:2px solid #333;border-radius:6px;font-size:18px;font-weight:600;color:#333;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 3px #00000014;transition:background .3s;flex-shrink:0}.overlay{position:absolute;inset-inline:0;display:grid;justify-items:center;gap:10px}.overlay.center{inset-block:0;place-content:center}.overlay.top{top:10px}.countdown{font-size:72px;font-weight:800;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.35)}.timer{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:#00000059;color:#fff;font-weight:600}.timer .dot{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1s infinite}.processing-label{color:#fff;font-weight:600}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .9s linear infinite}.controls{width:min(100%,420px);display:grid;gap:10px}.result-actions{display:grid;grid-auto-flow:column;gap:10px}.hint{font-size:13px;color:#a00;text-align:center}.btn{appearance:none;border:none;padding:10px 14px;border-radius:12px;font-weight:600;cursor:pointer;background:#eee}.btn.primary{background:#2b6ef2;color:#fff}.btn.success{background:#16a34a;color:#fff}.btn.danger{background:#ef4444;color:#fff}.btn.ghost{background:#00000059;color:#fff}.btn:disabled{opacity:.6;cursor:not-allowed}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceRecorderComponent, decorators: [{ type: Component, args: [{ selector: 'bio-face-recorder', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"recorder-wrapper\" [class.result]=\"state === 'result'\">\n <!-- Video frame (9:16) -->\n <div class=\"frame\">\n <video #videoEl class=\"preview\" autoplay playsinline muted preload=\"metadata\"></video>\n\n <!-- Oval dashed overlay -->\n <div class=\"oval-area\" *ngIf=\"state !== 'result' && state !== 'processing'\">\n <span class=\"guidance-text\">Place your face in the oval</span>\n <div class=\"oval-dashed\"></div>\n </div>\n\n <!-- HUD: digits to read -->\n <!-- <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div> -->\n\n <!-- HUD: countdown -->\n <div class=\"overlay center\" *ngIf=\"state === 'countdown'\">\n <div class=\"countdown\">{{ countdownLeft }}</div>\n <button class=\"btn ghost\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: recording timer + cancel -->\n <div class=\"overlay top\" *ngIf=\"state === 'recording'\">\n <div class=\"timer\">\n <span class=\"dot\"></span>\n <span>{{ recordLeft }}s</span>\n </div>\n <button class=\"btn danger\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: processing -->\n <div class=\"overlay center\" *ngIf=\"state === 'processing'\">\n <div class=\"spinner\"></div>\n <div class=\"processing-label\">Processing\u2026</div>\n </div>\n </div>\n\n <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div>\n\n <!-- Controls / CTA -->\n <div class=\"controls\">\n <!-- Preparation -->\n <ng-container *ngIf=\"state === 'preparation'\">\n <div class=\"hint\" *ngIf=\"permissionsGranted === false\">\n Camera access denied. Please allow camera & audio and try again.\n </div>\n <button class=\"btn primary\" type=\"button\" (click)=\"startFlow()\" [disabled]=\"!permissionsGranted\">\n Start recording\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"requestPermissions()\">Retry camera</button>\n </ng-container>\n\n <!-- Result -->\n <ng-container *ngIf=\"state === 'result'\">\n <div class=\"result-actions\">\n <button class=\"btn success\" type=\"button\" [disabled]=\"isConfirming\" (click)=\"handleConfirm()\">\n {{ isConfirming ? 'Confirming\u2026' : 'Use this video' }}\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"handleDecline()\">Retake</button>\n </div>\n </ng-container>\n </div>\n</div>", styles: [".recorder-wrapper{display:grid;gap:12px;justify-items:center;width:100%}.frame{position:relative;width:100%;max-width:320px;aspect-ratio:9/16;background:#000;border-radius:20px;overflow:hidden;box-shadow:0 6px 20px #00000040}.preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.oval-area{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}.guidance-text{position:absolute;top:13%;left:50%;transform:translate(-50%);text-wrap:nowrap;color:#fff;background:#00000059;padding:6px 10px;text-align:center;border-radius:30px;font-size:12px;letter-spacing:.2px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.oval-dashed{width:70%;height:58%;border:3px dashed rgba(255,255,255,.8);border-radius:50%;box-shadow:0 0 0 9999px #00000026 inset}.digits{width:min(100%,420px);display:grid;gap:8px;justify-items:center;margin-top:4px}.digits-label{font-size:13px;opacity:.8;text-align:center}.digits-row{display:flex;justify-content:center;gap:8px;flex-wrap:nowrap;overflow-x:auto}.digit-box{width:28px;height:36px;background:#f5f5f5;border:2px solid #333;border-radius:6px;font-size:18px;font-weight:600;color:#333;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 3px #00000014;transition:background .3s;flex-shrink:0}.overlay{position:absolute;inset-inline:0;display:grid;justify-items:center;gap:10px}.overlay.center{inset-block:0;place-content:center}.overlay.top{top:10px}.countdown{font-size:72px;font-weight:800;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.35)}.timer{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:#00000059;color:#fff;font-weight:600}.timer .dot{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1s infinite}.processing-label{color:#fff;font-weight:600}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .9s linear infinite}.controls{width:min(100%,420px);display:grid;gap:10px}.result-actions{display:grid;grid-auto-flow:column;gap:10px}.hint{font-size:13px;color:#a00;text-align:center}.btn{appearance:none;border:none;padding:10px 14px;border-radius:12px;font-weight:600;cursor:pointer;background:#eee}.btn.primary{background:#2b6ef2;color:#fff}.btn.success{background:#16a34a;color:#fff}.btn.danger{background:#ef4444;color:#fff}.btn.ghost{background:#00000059;color:#fff}.btn:disabled{opacity:.6;cursor:not-allowed}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}\n"] }] }], ctorParameters: () => [{ type: PermissionsService }, { type: RecorderService }, { type: i0.ChangeDetectorRef }], propDecorators: { videoRef: [{ type: ViewChild, args: ['videoEl', { static: true }] }], countdownSeconds: [{ type: Input }], recordingSeconds: [{ type: Input }], videoWidth: [{ type: Input }], videoHeight: [{ type: Input }], capture: [{ type: Output }] } }); function numbersToPhrase(digits) { const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; return digits.map(d => (d >= 0 && d <= 9 ? words[d] : String(d))).join(' '); } function generateRandomDigits(count) { const digits = Array.from({ length: 10 }, (_, i) => i); for (let i = digits.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [digits[i], digits[j]] = [digits[j], digits[i]]; } return digits.slice(0, count); } const RECORD_SECONDS = 10; const PREP_SECONDS = 3; class VoiceRecorderComponent { perms; recorder; ngZone; cdr; rectWidth = 360; rectHeight; noShadow = false; className; style; capture = new EventEmitter(); audioRef; permission = false; isLoading = true; stream; state = 'preparation'; prepTimer = PREP_SECONDS; recordTimer = RECORD_SECONDS; // digits & phrase digits = generateRandomDigits(10); maskedDigits = Array(10).fill('*'); // visualization audioLevel = 0; waveformData = []; // blobs/urls audioBlob = null; audioUrl = null; isConfirming = false; // timers / raf countdownTimeout; recordInterval; rafId; // audio context nodes audioCtx; analyser; sourceNode; constructor(perms, recorder, ngZone, cdr) { this.perms = perms; this.recorder = recorder; this.ngZone = ngZone; this.cdr = cdr; } async ngOnInit() { try { const status = await (navigator.permissions?.query?.({ name: 'microphone' }) ?? Promise.reject()); this.ngZone.run(() => { this.permission = status.state === 'granted'; this.isLoading = false; this.cdr.detectChanges(); }); if (this.permission) await this.requestPermissions(); status.onchange = async () => { this.ngZone.run(async () => { this.permission = status.state === 'granted'; if (this.permission && !this.stream) await this.requestPermissions(); this.cdr.detectChanges(); }); }; } catch { this.ngZone.run(() => { this.permission = false; this.isLoading = false; this.cdr.detectChanges(); }); } } ngOnDestroy() { this.clearAllTimers(); this.teardownVisualizer(); this.perms.stopStream(this.stream); if (this.audioUrl) URL.revokeObjectURL(this.audioUrl); } async requestPermissions() { this.ngZone.run(() => { this.isLoading = true; this.cdr.detectChanges(); }); const { stream, granted } = await this.perms.requestMicrophone(); this.ngZone.run(() => { this.permission = !!granted; this.isLoading = false; if (granted && stream) this.stream = stream; this.cdr.detectChanges(); }); } startRecording() { this.digits = generateRandomDigits(10); this.recordTimer = RECORD_SECONDS; this.prepTimer = PREP_SECONDS; this.state = 'countdown'; this.tickCountdown(); } tickCountdown() { if (this.prepTimer > 0) { this.countdownTimeout = setTimeout(() => { this.ngZone.run(() => { this.prepTimer -= 1; this.cdr.detectChanges(); }); this.tickCountdown(); }, 1000); return; } this.ngZone.run(() => { this.state = 'recording'; this.prepTimer = PREP_SECONDS; this.cdr.detectChanges(); }); this.beginRecording(); } async beginRecording() { if (!this.stream) { const { stream, granted } = await this.perms.requestMicrophone(); if (!granted || !stream) return;