smartcomply-web-sdk 1.0.80 → 1.0.82

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.
Files changed (37) hide show
  1. package/dist/camera/ActionDetector.d.ts.map +1 -1
  2. package/dist/camera/ActionDetector.js +7 -5
  3. package/dist/camera/ActionDetector.js.map +1 -1
  4. package/dist/client/Smartcomply.d.ts +1 -1
  5. package/dist/client/Smartcomply.js +1 -1
  6. package/dist/esm/camera/ActionDetector.d.ts.map +1 -1
  7. package/dist/esm/camera/ActionDetector.js +7 -5
  8. package/dist/esm/camera/ActionDetector.js.map +1 -1
  9. package/dist/esm/client/Smartcomply.d.ts +1 -1
  10. package/dist/esm/client/Smartcomply.js +1 -1
  11. package/dist/esm/flow/DocumentCapture.d.ts +42 -5
  12. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
  13. package/dist/esm/flow/DocumentCapture.js +176 -19
  14. package/dist/esm/flow/DocumentCapture.js.map +1 -1
  15. package/dist/esm/flow/SmartComplyFlow.d.ts +10 -0
  16. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  17. package/dist/esm/flow/SmartComplyFlow.js +53 -0
  18. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  19. package/dist/esm/utils/ImageQuality.d.ts +38 -0
  20. package/dist/esm/utils/ImageQuality.d.ts.map +1 -1
  21. package/dist/esm/utils/ImageQuality.js +98 -0
  22. package/dist/esm/utils/ImageQuality.js.map +1 -1
  23. package/dist/flow/DocumentCapture.d.ts +42 -5
  24. package/dist/flow/DocumentCapture.d.ts.map +1 -1
  25. package/dist/flow/DocumentCapture.js +175 -18
  26. package/dist/flow/DocumentCapture.js.map +1 -1
  27. package/dist/flow/SmartComplyFlow.d.ts +10 -0
  28. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  29. package/dist/flow/SmartComplyFlow.js +53 -0
  30. package/dist/flow/SmartComplyFlow.js.map +1 -1
  31. package/dist/smartcomply.browser.js +256 -11
  32. package/dist/smartcomply.browser.js.map +3 -3
  33. package/dist/utils/ImageQuality.d.ts +38 -0
  34. package/dist/utils/ImageQuality.d.ts.map +1 -1
  35. package/dist/utils/ImageQuality.js +99 -0
  36. package/dist/utils/ImageQuality.js.map +1 -1
  37. package/package.json +1 -1
@@ -4665,7 +4665,7 @@ var SmartComplySDK = (() => {
4665
4665
  faceDetected,
4666
4666
  blinkDetected: this.blinkDetected,
4667
4667
  motionDetected: this.motionDetected,
4668
- livenessConfirmed: this.blinkDetected || this.motionDetected
4668
+ livenessConfirmed: this.blinkDetected && this.motionDetected
4669
4669
  };
4670
4670
  }
4671
4671
  getHeadYaw(matrix) {
@@ -5780,7 +5780,7 @@ var SmartComplySDK = (() => {
5780
5780
  * CDN tag they installed with (e.g. @1, which floats to the latest
5781
5781
  * matching release) actually resolved to the version they expect.
5782
5782
  */
5783
- _SmartComply.VERSION = "1.0.80";
5783
+ _SmartComply.VERSION = "1.0.82";
5784
5784
  var SmartComply = _SmartComply;
5785
5785
 
5786
5786
  // src/flow/theme.ts
@@ -6040,6 +6040,67 @@ var SmartComplySDK = (() => {
6040
6040
  quality: metrics
6041
6041
  };
6042
6042
  }
6043
+ function assessFraming(imageData, guideBoxPct) {
6044
+ const { width: W2, height: H2, data } = imageData;
6045
+ const gray = new Uint8Array(W2 * H2);
6046
+ for (let i2 = 0; i2 < W2 * H2; i2++) {
6047
+ const idx = i2 * 4;
6048
+ gray[i2] = Math.round(0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]);
6049
+ }
6050
+ const gx0 = Math.round(guideBoxPct.x * W2);
6051
+ const gy0 = Math.round(guideBoxPct.y * H2);
6052
+ const gx1 = Math.round((guideBoxPct.x + guideBoxPct.width) * W2);
6053
+ const gy1 = Math.round((guideBoxPct.y + guideBoxPct.height) * H2);
6054
+ const edgeAt = (x2, y2) => {
6055
+ if (x2 < 1 || y2 < 1 || x2 >= W2 - 1 || y2 >= H2 - 1) return 0;
6056
+ const gxVal = gray[y2 * W2 + x2 + 1] - gray[y2 * W2 + x2 - 1];
6057
+ const gyVal = gray[(y2 + 1) * W2 + x2] - gray[(y2 - 1) * W2 + x2];
6058
+ return Math.sqrt(gxVal * gxVal + gyVal * gyVal);
6059
+ };
6060
+ const EDGE_THRESHOLD = 40;
6061
+ const BORDER_BAND = Math.max(4, Math.round(Math.min(W2, H2) * 0.015));
6062
+ let borderPoints = 0;
6063
+ let borderHits = 0;
6064
+ const SAMPLES_PER_SIDE = 24;
6065
+ const sampleBand = (fixed, isX, start, end) => {
6066
+ for (let s2 = 0; s2 < SAMPLES_PER_SIDE; s2++) {
6067
+ const t2 = start + (end - start) * s2 / (SAMPLES_PER_SIDE - 1);
6068
+ let bestEdge = 0;
6069
+ for (let b2 = -BORDER_BAND; b2 <= BORDER_BAND; b2++) {
6070
+ const x2 = isX ? fixed + b2 : Math.round(t2);
6071
+ const y2 = isX ? Math.round(t2) : fixed + b2;
6072
+ bestEdge = Math.max(bestEdge, edgeAt(x2, y2));
6073
+ }
6074
+ borderPoints++;
6075
+ if (bestEdge >= EDGE_THRESHOLD) borderHits++;
6076
+ }
6077
+ };
6078
+ sampleBand(gy0, false, gx0, gx1);
6079
+ sampleBand(gy1, false, gx0, gx1);
6080
+ sampleBand(gx0, true, gy0, gy1);
6081
+ sampleBand(gx1, true, gy0, gy1);
6082
+ const borderEdgeCoverage = borderPoints > 0 ? borderHits / borderPoints : 0;
6083
+ let sum = 0, sumSq = 0, n2 = 0;
6084
+ const GRID = 12;
6085
+ const insetX = Math.round((gx1 - gx0) * 0.1);
6086
+ const insetY = Math.round((gy1 - gy0) * 0.1);
6087
+ for (let iy = 0; iy < GRID; iy++) {
6088
+ for (let ix = 0; ix < GRID; ix++) {
6089
+ const x2 = gx0 + insetX + Math.round((gx1 - gx0 - 2 * insetX) * ix / (GRID - 1));
6090
+ const y2 = gy0 + insetY + Math.round((gy1 - gy0 - 2 * insetY) * iy / (GRID - 1));
6091
+ if (x2 < 0 || y2 < 0 || x2 >= W2 || y2 >= H2) continue;
6092
+ const v2 = gray[y2 * W2 + x2];
6093
+ sum += v2;
6094
+ sumSq += v2 * v2;
6095
+ n2++;
6096
+ }
6097
+ }
6098
+ const mean = n2 > 0 ? sum / n2 : 0;
6099
+ const variance = n2 > 0 ? sumSq / n2 - mean * mean : 0;
6100
+ const interiorVariance = Math.min(1, Math.sqrt(Math.max(0, variance)) / 35);
6101
+ const isFramed = borderEdgeCoverage >= 0.55 && interiorVariance >= 0.25;
6102
+ return { isFramed, borderEdgeCoverage, interiorVariance };
6103
+ }
6043
6104
 
6044
6105
  // src/flow/icons.ts
6045
6106
  var PATHS = {
@@ -6130,6 +6191,9 @@ var SmartComplySDK = (() => {
6130
6191
  this.backBlob = null;
6131
6192
  this.currentSide = "front";
6132
6193
  this.requiresBackOverride = void 0;
6194
+ this.framingTimer = null;
6195
+ this.framingStableCount = 0;
6196
+ this.framingAutoTriggered = false;
6133
6197
  }
6134
6198
  /**
6135
6199
  * Mount the document capture UI and return a promise that resolves
@@ -6414,6 +6478,13 @@ var SmartComplySDK = (() => {
6414
6478
  padding:14px 16px 32px;text-align:center;
6415
6479
  `;
6416
6480
  header.innerHTML = `<div style="color:#fff;font-size:${TYPE.sm}px;font-weight:600;">Align your ${this.getDocumentLabel(documentType)} within the frame</div>`;
6481
+ const framingStatus = document.createElement("div");
6482
+ framingStatus.style.cssText = `
6483
+ margin-top:6px;font-size:${TYPE.xs}px;font-weight:600;
6484
+ color:rgba(255,255,255,0.7);transition:color 0.2s ease;
6485
+ `;
6486
+ framingStatus.textContent = "Fit the document inside the frame";
6487
+ header.appendChild(framingStatus);
6417
6488
  wrapper.appendChild(header);
6418
6489
  const footer = document.createElement("div");
6419
6490
  footer.style.cssText = `
@@ -6446,10 +6517,19 @@ var SmartComplySDK = (() => {
6446
6517
  inner.style.transform = "translate(-50%,-50%) scale(1)";
6447
6518
  });
6448
6519
  captureBtn.addEventListener("click", () => {
6520
+ this.framingAutoTriggered = true;
6449
6521
  this.captureFrame(container, theme, documentType);
6450
6522
  });
6451
6523
  container.appendChild(captureBtn);
6452
- this.openCamera();
6524
+ this.openCamera().then(() => {
6525
+ if (this.videoEl) {
6526
+ this.videoEl.addEventListener(
6527
+ "loadedmetadata",
6528
+ () => this._startFramingLoop(container, theme, documentType, framingStatus),
6529
+ { once: true }
6530
+ );
6531
+ }
6532
+ });
6453
6533
  }
6454
6534
  /**
6455
6535
  * Matches the video box's aspect-ratio CSS to the stream's actual
@@ -6484,6 +6564,90 @@ var SmartComplySDK = (() => {
6484
6564
  this.orientationHandler = null;
6485
6565
  }
6486
6566
  }
6567
+ /**
6568
+ * Auto-capture-on-stability — the same pattern the liveness module already
6569
+ * uses for face-lock (see liveness.ts's checkFace), applied to documents:
6570
+ * poll the raw video frame on an interval, score it against the guide box
6571
+ * with assessFraming() (see utils/ImageQuality.ts), and fire the shutter
6572
+ * automatically once several consecutive samples score as "framed". This
6573
+ * is industry-standard for document capture (Persona/Onfido/Jumio-style
6574
+ * SDKs all auto-capture rather than trust a manual tap against a static
6575
+ * guide overlay) — the guide box alone never blocked a badly cropped tap.
6576
+ *
6577
+ * The manual capture button still works throughout and always wins
6578
+ * (framingAutoTriggered guards against a race between the two).
6579
+ */
6580
+ _startFramingLoop(container, theme, documentType, statusEl) {
6581
+ if (!this.videoEl) return;
6582
+ this.framingStableCount = 0;
6583
+ this.framingAutoTriggered = false;
6584
+ const STABLE_REQUIRED = 3;
6585
+ const SAMPLE_INTERVAL_MS = 350;
6586
+ const MAX_WAIT_MS = 12e3;
6587
+ const startTime = performance.now();
6588
+ const boxWidthPct = 0.84;
6589
+ const boxAspect = 16 / 10;
6590
+ const tick = () => {
6591
+ if (!this.videoEl || this.framingAutoTriggered) return;
6592
+ if (performance.now() - startTime >= MAX_WAIT_MS) {
6593
+ statusEl.textContent = "Fit the document inside the frame";
6594
+ return;
6595
+ }
6596
+ const w2 = this.videoEl.videoWidth;
6597
+ const h2 = this.videoEl.videoHeight;
6598
+ if (!w2 || !h2 || this.videoEl.readyState < 2) {
6599
+ this.framingTimer = setTimeout(tick, SAMPLE_INTERVAL_MS);
6600
+ return;
6601
+ }
6602
+ try {
6603
+ const boxWidthPx = w2 * boxWidthPct;
6604
+ const boxHeightPx = boxWidthPx / boxAspect;
6605
+ const boxXPct = (1 - boxWidthPct) / 2;
6606
+ const boxHeightPct = boxHeightPx / h2;
6607
+ const boxYPct = (1 - boxHeightPct) / 2;
6608
+ const canvas = document.createElement("canvas");
6609
+ const scale = Math.min(1, 480 / w2);
6610
+ canvas.width = Math.round(w2 * scale);
6611
+ canvas.height = Math.round(h2 * scale);
6612
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
6613
+ if (!ctx) {
6614
+ this.framingTimer = setTimeout(tick, SAMPLE_INTERVAL_MS);
6615
+ return;
6616
+ }
6617
+ ctx.drawImage(this.videoEl, 0, 0, canvas.width, canvas.height);
6618
+ const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
6619
+ const result = assessFraming(pixels, {
6620
+ x: boxXPct,
6621
+ y: boxYPct,
6622
+ width: boxWidthPct,
6623
+ height: boxHeightPct
6624
+ });
6625
+ if (result.isFramed) {
6626
+ this.framingStableCount++;
6627
+ statusEl.style.color = theme.primary;
6628
+ statusEl.textContent = this.framingStableCount >= STABLE_REQUIRED ? "Capturing…" : "Hold steady…";
6629
+ } else {
6630
+ this.framingStableCount = 0;
6631
+ statusEl.style.color = "rgba(255,255,255,0.7)";
6632
+ statusEl.textContent = result.interiorVariance < 0.25 ? "Move closer — fill the frame with your document" : "Align all 4 corners within the guide";
6633
+ }
6634
+ if (this.framingStableCount >= STABLE_REQUIRED) {
6635
+ this.framingAutoTriggered = true;
6636
+ this.captureFrame(container, theme, documentType);
6637
+ return;
6638
+ }
6639
+ } catch {
6640
+ }
6641
+ this.framingTimer = setTimeout(tick, SAMPLE_INTERVAL_MS);
6642
+ };
6643
+ this.framingTimer = setTimeout(tick, SAMPLE_INTERVAL_MS);
6644
+ }
6645
+ _stopFramingLoop() {
6646
+ if (this.framingTimer !== null) {
6647
+ clearTimeout(this.framingTimer);
6648
+ this.framingTimer = null;
6649
+ }
6650
+ }
6487
6651
  async openCamera() {
6488
6652
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
6489
6653
  this.rejectCapture?.(new Error("Camera access is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari."));
@@ -6614,7 +6778,7 @@ var SmartComplySDK = (() => {
6614
6778
  URL.revokeObjectURL(currentUrl);
6615
6779
  currentUrl = URL.createObjectURL(rotated);
6616
6780
  img.src = currentUrl;
6617
- this._checkPhotoQuality(currentBlob, qualityNotice, theme);
6781
+ this._checkPhotoQuality(currentBlob, qualityNotice, theme, useBtn);
6618
6782
  } catch {
6619
6783
  } finally {
6620
6784
  isRotating = false;
@@ -6640,7 +6804,6 @@ var SmartComplySDK = (() => {
6640
6804
  `;
6641
6805
  qualityNotice.setAttribute("role", "status");
6642
6806
  this.root.appendChild(qualityNotice);
6643
- this._checkPhotoQuality(currentBlob, qualityNotice, theme);
6644
6807
  const btnRow = document.createElement("div");
6645
6808
  btnRow.style.cssText = "display:flex;gap:12px;width:100%;";
6646
6809
  const retakeBtn = this.createButton("Retake", theme, false);
@@ -6662,20 +6825,58 @@ var SmartComplySDK = (() => {
6662
6825
  btnRow.appendChild(retakeBtn);
6663
6826
  btnRow.appendChild(useBtn);
6664
6827
  this.root.appendChild(btnRow);
6828
+ this._checkPhotoQuality(currentBlob, qualityNotice, theme, useBtn);
6665
6829
  }
6666
6830
  /**
6667
- * Runs the shared client-side quality heuristic (blur/glare/contrast/
6668
- * brightness — see utils/ImageQuality.ts) against the just-captured photo
6669
- * and, if it scores poorly, shows a plain-language nudge on the preview
6670
- * screen. Fails silently on decode error — this is an advisory extra, not
6671
- * a required step, so a browser that can't run it must not block capture.
6831
+ * Runs the shared client-side quality heuristics — blur/glare/contrast/
6832
+ * brightness (assessQuality) and document framing/completeness
6833
+ * (assessFraming) — against the just-captured or just-uploaded photo, and
6834
+ * shows a plain-language nudge on the preview screen if either scores
6835
+ * poorly. This is the one chokepoint both the camera and manual-upload
6836
+ * paths funnel through (see showPreview), so it's the only place that
6837
+ * reliably catches a badly cropped document regardless of how the photo
6838
+ * arrived — the camera's own auto-capture framing loop only guards live
6839
+ * capture, and does nothing for an uploaded file or a manual shutter tap
6840
+ * fired before the loop ever samples.
6841
+ *
6842
+ * Framing here checks the WHOLE image (guideBoxPct = full frame), not a
6843
+ * guide-box region — there's no guide overlay for an arbitrary uploaded
6844
+ * photo, so "does the document fill most of the frame" is the correct
6845
+ * question, matching the same border-edge + interior-variance heuristic
6846
+ * the camera loop already uses.
6847
+ *
6848
+ * A framing failure disables useBtn — unlike the blur/glare nudge, this
6849
+ * one blocks: a cropped ID reads as a confident, wrong identity downstream
6850
+ * (partial OCR fields, a registry check against a truncated ID number)
6851
+ * with no further chance to catch it, so the user must retake/re-upload
6852
+ * rather than being trusted to notice an advisory nudge and act on it
6853
+ * voluntarily. Fails open on decode error — this heuristic not running at
6854
+ * all must not lock the user out of a photo it never actually scored.
6672
6855
  */
6673
- async _checkPhotoQuality(imageData, noticeEl, theme) {
6856
+ async _checkPhotoQuality(imageData, noticeEl, theme, useBtn) {
6674
6857
  try {
6675
6858
  const canvas = await this._blobToCanvas(imageData, 640);
6676
6859
  const ctx = canvas.getContext("2d");
6677
6860
  if (!ctx) return;
6678
6861
  const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
6862
+ const framing = assessFraming(pixels, { x: 0, y: 0, width: 1, height: 1 });
6863
+ if (!framing.isFramed) {
6864
+ if (noticeEl.isConnected) {
6865
+ noticeEl.style.display = "block";
6866
+ noticeEl.textContent = framing.interiorVariance < 0.25 ? "We couldn't detect a document in this photo — make sure the ID is clearly visible and try again." : "Part of the document looks cut off — make sure all four corners are visible and try again.";
6867
+ }
6868
+ if (useBtn.isConnected) {
6869
+ useBtn.disabled = true;
6870
+ useBtn.style.opacity = "0.5";
6871
+ useBtn.style.cursor = "not-allowed";
6872
+ }
6873
+ return;
6874
+ }
6875
+ if (useBtn.isConnected) {
6876
+ useBtn.disabled = false;
6877
+ useBtn.style.opacity = "1";
6878
+ useBtn.style.cursor = "pointer";
6879
+ }
6679
6880
  const result = assessQuality(pixels);
6680
6881
  if (noticeEl.isConnected && !result.isValid) {
6681
6882
  noticeEl.style.display = "block";
@@ -6818,6 +7019,7 @@ var SmartComplySDK = (() => {
6818
7019
  if (input) input.click();
6819
7020
  }
6820
7021
  stopCamera() {
7022
+ this._stopFramingLoop();
6821
7023
  this._unwatchOrientation();
6822
7024
  this.cameraManager.stop();
6823
7025
  if (this.videoEl) {
@@ -8403,6 +8605,11 @@ var SmartComplySDK = (() => {
8403
8605
  this._renderOcrQualityFail(container, result.ocr_rejection_reason);
8404
8606
  return;
8405
8607
  }
8608
+ if (result.status === "failed") {
8609
+ container.innerHTML = "";
8610
+ this._renderDocumentVerificationFailed(container, result.failure_reason);
8611
+ return;
8612
+ }
8406
8613
  const ocrDone = result.ocr_status === "done" || result.ocr_status === "ocr_complete" || result.ocr_name || result.ocr_document_number || result.ocr_expiry_date;
8407
8614
  if (ocrDone) {
8408
8615
  this._ocrName = result.ocr_name || "";
@@ -8495,6 +8702,44 @@ var SmartComplySDK = (() => {
8495
8702
  });
8496
8703
  container.appendChild(retryBtn);
8497
8704
  }
8705
+ /**
8706
+ * The document passed OCR's own quality gate (it was readable) but failed
8707
+ * the government-registry re-check — the ID number either doesn't exist
8708
+ * in the registry, or belongs to a different person than the name on the
8709
+ * document. Distinct from _renderOcrQualityFail: the photo itself may be
8710
+ * perfectly clear, so "retake photo" framing would be misleading — the
8711
+ * fix is a correct, uncropped photo of the actual document, not just a
8712
+ * clearer one.
8713
+ */
8714
+ _renderDocumentVerificationFailed(container, failureReason) {
8715
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
8716
+ const icon2 = document.createElement("div");
8717
+ icon2.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
8718
+ icon2.textContent = "⚠️";
8719
+ container.appendChild(icon2);
8720
+ const t2 = document.createElement("h2");
8721
+ t2.style.cssText = `color:${this.theme.text};font-size:${TYPE.heading}px;font-weight:700;margin:0;`;
8722
+ t2.textContent = "We Couldn't Confirm Your Details";
8723
+ container.appendChild(t2);
8724
+ const d2 = document.createElement("p");
8725
+ d2.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
8726
+ d2.textContent = failureReason || "We couldn't confirm your details. Please re-upload a clear photo of your ID and make sure your details are correct before submitting.";
8727
+ container.appendChild(d2);
8728
+ const retryBtn = this.createPrimaryButton("Re-upload ID");
8729
+ retryBtn.addEventListener("click", () => {
8730
+ this.livenessEntryId = null;
8731
+ this.documentBlob = null;
8732
+ this.documentBackBlob = null;
8733
+ this.showStep("document_capture");
8734
+ });
8735
+ container.appendChild(retryBtn);
8736
+ const endBtn = this.createSecondaryButton("End session");
8737
+ endBtn.addEventListener("click", () => {
8738
+ this.options.onError?.(new Error("Document verification failed"));
8739
+ this.close();
8740
+ });
8741
+ container.appendChild(endBtn);
8742
+ }
8498
8743
  /**
8499
8744
  * OCR ran to completion but never produced a single usable field within
8500
8745
  * the poll window — do not let the flow reach liveness with no verified