scandoc-ai-components 0.1.17 → 0.1.19

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 (2) hide show
  1. package/dist/index.js +474 -482
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11174,16 +11174,6 @@ class ExtractorVideo {
11174
11174
  static VALIDATION_BATCH_SIZE = 1;
11175
11175
  static VALIDATION_IMG_WIDTH = 384;
11176
11176
  static VALIDATION_IMG_HEIGHT = 384;
11177
- static VIDEO_CANDIDATE_SETTINGS = Object.freeze([{
11178
- width: 1920,
11179
- height: 1080
11180
- }, {
11181
- width: 1280,
11182
- height: 960
11183
- }, {
11184
- width: 1280,
11185
- height: 720
11186
- }]);
11187
11177
  constructor(onExtractedResults) {
11188
11178
  this.candidateImages = [];
11189
11179
  this.extractionImages = {};
@@ -11197,7 +11187,8 @@ class ExtractorVideo {
11197
11187
  this.waitingForSecondSide = false;
11198
11188
  this.hasShownTurnMessage = false;
11199
11189
  this.turnMessageTimer = null;
11200
- this.onExtractedResults = onExtractedResults;
11190
+ this.currentStream = null;
11191
+ this.lastCameraInfo = null;
11201
11192
  }
11202
11193
  reset() {
11203
11194
  this.stopVideo();
@@ -11213,6 +11204,7 @@ class ExtractorVideo {
11213
11204
  this.lastMessage = null;
11214
11205
  this.waitingForSecondSide = false;
11215
11206
  this.hasShownTurnMessage = false;
11207
+ this.lastCameraInfo = null;
11216
11208
  clearTimeout(this.turnMessageTimer);
11217
11209
  this.turnMessageTimer = null;
11218
11210
  }
@@ -11365,7 +11357,7 @@ class ExtractorVideo {
11365
11357
  this.turnMessageTimer = null;
11366
11358
  this.stopVideo();
11367
11359
  if (isExtractionOk) {
11368
- this.showMessage("Success - data extracted", "success");
11360
+ this.showMessage("Success - data extracted", true);
11369
11361
  const shouldStop = this.onExtractedResults({
11370
11362
  success: true,
11371
11363
  code: "001",
@@ -11373,7 +11365,7 @@ class ExtractorVideo {
11373
11365
  data: extractionData
11374
11366
  });
11375
11367
  if (shouldStop === true) {
11376
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11368
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11377
11369
  }
11378
11370
  } else {
11379
11371
  this.onExtractedResults({
@@ -11381,378 +11373,8 @@ class ExtractorVideo {
11381
11373
  code: "005",
11382
11374
  info: "Document validation passed but extraction failed."
11383
11375
  });
11384
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11385
- }
11386
- }
11387
- getSupportedConstraintsSafe() {
11388
- if (!navigator.mediaDevices?.getSupportedConstraints) return {};
11389
- try {
11390
- return navigator.mediaDevices.getSupportedConstraints();
11391
- } catch (_) {
11392
- return {};
11393
- }
11394
- }
11395
- getAspectRatio(settings) {
11396
- if (typeof settings?.aspectRatio === "number" && settings.aspectRatio > 0) {
11397
- return settings.aspectRatio;
11398
- }
11399
- if (settings?.width && settings?.height) {
11400
- return settings.width / settings.height;
11401
- }
11402
- return null;
11403
- }
11404
- isLandscape(settings) {
11405
- return (settings?.width || 0) > (settings?.height || 0);
11406
- }
11407
- isFrontLabel(label) {
11408
- const l = (label || "").toLowerCase();
11409
- return l.includes("front") || l.includes("selfie") || l.includes("user facing") || l.includes("facing front");
11410
- }
11411
- isRearLikeLabel(label) {
11412
- const l = (label || "").toLowerCase();
11413
- return l.includes("rear") || l.includes("back") || l.includes("environment") || l.includes("facing back") || l.includes("facing rear");
11414
- }
11415
- scoreTrack({
11416
- settings,
11417
- label
11418
- }) {
11419
- const width = settings?.width || 0;
11420
- const height = settings?.height || 0;
11421
- const pixels = width * height;
11422
- const aspectRatio = this.getAspectRatio(settings) || 0;
11423
- let score = pixels;
11424
- if (width > height) score += 1_000_000_000;
11425
- if (aspectRatio >= 1.3) score += 100_000_000;
11426
- if (this.isRearLikeLabel(label)) score += 10_000_000;
11427
- if (this.isFrontLabel(label)) score -= 10_000_000_000;
11428
- return score;
11429
- }
11430
- buildVideoConstraints({
11431
- width,
11432
- height,
11433
- mode,
11434
- deviceId
11435
- }) {
11436
- const supported = this.getSupportedConstraintsSafe();
11437
- const video = {};
11438
- if (deviceId) {
11439
- video.deviceId = {
11440
- exact: deviceId
11441
- };
11442
- } else if (supported.facingMode) {
11443
- if (typeof mode === "string") {
11444
- video.facingMode = {
11445
- ideal: mode
11446
- };
11447
- } else if (mode && typeof mode === "object") {
11448
- video.facingMode = mode;
11449
- } else {
11450
- video.facingMode = {
11451
- ideal: "environment"
11452
- };
11453
- }
11454
- }
11455
- if (supported.width) {
11456
- video.width = {
11457
- ideal: width
11458
- };
11459
- }
11460
- if (supported.height) {
11461
- video.height = {
11462
- ideal: height
11463
- };
11464
- }
11465
- if (supported.aspectRatio) {
11466
- video.aspectRatio = {
11467
- ideal: width / height
11468
- };
11469
- }
11470
- return {
11471
- video,
11472
- audio: false
11473
- };
11474
- }
11475
- async pauseBetweenCameraOps(ms = 180) {
11476
- await new Promise(resolve => setTimeout(resolve, ms));
11477
- }
11478
- async stopMediaStream(stream) {
11479
- if (!stream) return;
11480
- try {
11481
- stream.getTracks().forEach(t => t.stop());
11482
- } catch (_) {}
11483
- await this.pauseBetweenCameraOps();
11484
- }
11485
- async tryEnableDocumentFocus(stream) {
11486
- const track = stream?.getVideoTracks?.()[0];
11487
- if (!track) {
11488
- return {
11489
- ok: false,
11490
- reason: "No video track found"
11491
- };
11492
- }
11493
- if (!track.getCapabilities || !track.applyConstraints) {
11494
- return {
11495
- ok: false,
11496
- reason: "Focus APIs unavailable on this browser/device",
11497
- settingsBefore: track.getSettings ? track.getSettings() : null
11498
- };
11499
- }
11500
- const caps = track.getCapabilities();
11501
- const result = {
11502
- ok: true,
11503
- capabilities: caps,
11504
- attempts: [],
11505
- settingsBefore: track.getSettings ? track.getSettings() : null
11506
- };
11507
- try {
11508
- let applied = false;
11509
- if (Array.isArray(caps.focusMode) && caps.focusMode.includes("continuous")) {
11510
- await track.applyConstraints({
11511
- advanced: [{
11512
- focusMode: "continuous"
11513
- }]
11514
- });
11515
- result.attempts.push("Applied focusMode=continuous");
11516
- applied = true;
11517
- }
11518
- if (caps.focusDistance) {
11519
- const min = caps.focusDistance.min;
11520
- const max = caps.focusDistance.max;
11521
- const docDistance = min + (max - min) * 0.30;
11522
- const advanced = [];
11523
- if (Array.isArray(caps.focusMode) && caps.focusMode.includes("manual")) {
11524
- advanced.push({
11525
- focusMode: "manual"
11526
- });
11527
- }
11528
- advanced.push({
11529
- focusDistance: docDistance
11530
- });
11531
- await track.applyConstraints({
11532
- advanced
11533
- });
11534
- result.attempts.push(`Applied focusDistance=${docDistance}`);
11535
- applied = true;
11536
- }
11537
- if (!applied) {
11538
- result.ok = false;
11539
- result.reason = "No supported focus controls were exposed";
11540
- }
11541
- result.settingsAfter = track.getSettings ? track.getSettings() : null;
11542
- return result;
11543
- } catch (err) {
11544
- result.ok = false;
11545
- result.error = err.message || String(err);
11546
- result.settingsAfter = track.getSettings ? track.getSettings() : null;
11547
- return result;
11548
- }
11549
- }
11550
- async openCandidateStream({
11551
- mode,
11552
- deviceId,
11553
- width,
11554
- height
11555
- }) {
11556
- return navigator.mediaDevices.getUserMedia(this.buildVideoConstraints({
11557
- width,
11558
- height,
11559
- mode,
11560
- deviceId
11561
- }));
11562
- }
11563
- async testDeviceAtPreferredResolutions(device, mode) {
11564
- const attempts = [];
11565
- for (const size of ExtractorVideo.VIDEO_CANDIDATE_SETTINGS) {
11566
- let stream = null;
11567
- try {
11568
- stream = await this.openCandidateStream({
11569
- mode,
11570
- deviceId: device?.deviceId,
11571
- width: size.width,
11572
- height: size.height
11573
- });
11574
- const track = stream.getVideoTracks()[0];
11575
- const settings = track.getSettings();
11576
- const label = track.label || device?.label || "";
11577
- const aspectRatio = this.getAspectRatio(settings);
11578
- const result = {
11579
- stream,
11580
- label,
11581
- settings,
11582
- aspectRatio,
11583
- landscape: this.isLandscape(settings),
11584
- score: this.scoreTrack({
11585
- settings,
11586
- label
11587
- }),
11588
- requested: size
11589
- };
11590
- attempts.push({
11591
- ok: true,
11592
- requested: size,
11593
- returnedWidth: settings.width,
11594
- returnedHeight: settings.height,
11595
- aspectRatio,
11596
- label
11597
- });
11598
- return {
11599
- result,
11600
- attempts
11601
- };
11602
- } catch (err) {
11603
- attempts.push({
11604
- ok: false,
11605
- requested: size,
11606
- error: err.message || String(err)
11607
- });
11608
- await this.stopMediaStream(stream);
11609
- }
11376
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11610
11377
  }
11611
- return {
11612
- result: null,
11613
- attempts
11614
- };
11615
- }
11616
- async pickBestPhoneCamera(mode = "environment") {
11617
- const debug = {
11618
- mode,
11619
- tested: []
11620
- };
11621
-
11622
- // First try a warmup stream to unlock labels
11623
- let warmupStream = null;
11624
- let warmupResult = null;
11625
- try {
11626
- const warmupSize = ExtractorVideo.VIDEO_CANDIDATE_SETTINGS[2]; // 1280x720
11627
- warmupStream = await this.openCandidateStream({
11628
- mode,
11629
- width: warmupSize.width,
11630
- height: warmupSize.height
11631
- });
11632
- const track = warmupStream.getVideoTracks()[0];
11633
- const settings = track.getSettings();
11634
- const label = track.label || "";
11635
- warmupResult = {
11636
- stream: warmupStream,
11637
- label,
11638
- settings,
11639
- aspectRatio: this.getAspectRatio(settings),
11640
- landscape: this.isLandscape(settings),
11641
- score: this.scoreTrack({
11642
- settings,
11643
- label
11644
- }),
11645
- requested: warmupSize
11646
- };
11647
- } catch (_) {
11648
- warmupStream = null;
11649
- }
11650
- let devices = [];
11651
- try {
11652
- devices = await navigator.mediaDevices.enumerateDevices();
11653
- } catch (_) {
11654
- devices = [];
11655
- }
11656
- const videoInputs = devices.filter(d => d.kind === "videoinput");
11657
- const candidateDevices = mode === "environment" ? videoInputs.filter(d => !this.isFrontLabel(d.label)) : videoInputs;
11658
- if (warmupStream) {
11659
- await this.stopMediaStream(warmupStream);
11660
- warmupStream = null;
11661
- }
11662
- const usableResults = [];
11663
- for (let i = 0; i < candidateDevices.length; i += 1) {
11664
- const device = candidateDevices[i];
11665
- const {
11666
- result,
11667
- attempts
11668
- } = await this.testDeviceAtPreferredResolutions(device, mode);
11669
- debug.tested.push({
11670
- deviceId: device.deviceId,
11671
- label: device.label,
11672
- attempts,
11673
- final: result ? {
11674
- label: result.label,
11675
- settings: result.settings,
11676
- aspectRatio: result.aspectRatio,
11677
- landscape: result.landscape,
11678
- score: result.score,
11679
- requested: result.requested
11680
- } : null
11681
- });
11682
- if (result) {
11683
- const frontRejected = mode === "environment" && this.isFrontLabel(result.label);
11684
- const geometryOk = result.landscape;
11685
- if (!frontRejected && geometryOk) {
11686
- usableResults.push(result);
11687
- } else {
11688
- await this.stopMediaStream(result.stream);
11689
- }
11690
- }
11691
- }
11692
- if (usableResults.length > 0) {
11693
- usableResults.sort((a, b) => b.score - a.score);
11694
- const chosen = usableResults[0];
11695
- for (const candidate of usableResults) {
11696
- if (candidate !== chosen) {
11697
- await this.stopMediaStream(candidate.stream);
11698
- }
11699
- }
11700
- debug.chosen = {
11701
- label: chosen.label,
11702
- settings: chosen.settings,
11703
- aspectRatio: chosen.aspectRatio,
11704
- requested: chosen.requested,
11705
- source: "device-test"
11706
- };
11707
- return {
11708
- chosen,
11709
- debug
11710
- };
11711
- }
11712
-
11713
- // Final fallback: simple facingMode-based opens in descending priority
11714
- for (const size of ExtractorVideo.VIDEO_CANDIDATE_SETTINGS) {
11715
- try {
11716
- const stream = await this.openCandidateStream({
11717
- mode,
11718
- width: size.width,
11719
- height: size.height
11720
- });
11721
- const track = stream.getVideoTracks()[0];
11722
- const settings = track.getSettings();
11723
- const label = track.label || "";
11724
- const aspectRatio = this.getAspectRatio(settings);
11725
- const landscape = this.isLandscape(settings);
11726
- if (mode === "environment" && !landscape) {
11727
- await this.stopMediaStream(stream);
11728
- continue;
11729
- }
11730
- const chosen = {
11731
- stream,
11732
- label,
11733
- settings,
11734
- aspectRatio,
11735
- landscape,
11736
- score: this.scoreTrack({
11737
- settings,
11738
- label
11739
- }),
11740
- requested: size
11741
- };
11742
- debug.chosen = {
11743
- label,
11744
- settings,
11745
- aspectRatio,
11746
- requested: size,
11747
- source: "fallback-facingMode"
11748
- };
11749
- return {
11750
- chosen,
11751
- debug
11752
- };
11753
- } catch (_) {}
11754
- }
11755
- throw new Error("Could not start a suitable camera.");
11756
11378
  }
11757
11379
  async startVideo() {
11758
11380
  try {
@@ -11762,29 +11384,49 @@ class ExtractorVideo {
11762
11384
  if (!videoElem) {
11763
11385
  throw new Error("Video element not found.");
11764
11386
  }
11387
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
11388
+ throw new Error("getUserMedia is not available");
11389
+ }
11390
+ if (!window.isSecureContext) {
11391
+ throw new Error("Camera requires HTTPS / secure context");
11392
+ }
11765
11393
  this.video = videoElem;
11766
11394
  this.isRunning = true;
11767
11395
  this.scanStartTime = Date.now();
11768
11396
  const cfgValues = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfigValues)();
11769
11397
  const mode = cfgValues.VIDEO_FACING_MODE ?? "environment";
11770
- const {
11771
- chosen,
11772
- debug
11773
- } = await this.pickBestPhoneCamera(mode);
11774
- this.video.srcObject = chosen.stream;
11775
- await this.video.play().catch(e => {
11776
- console.warn(`Error on video play: ${e}`);
11398
+ await this.stopCurrentStream();
11399
+ const cameraResult = await openBestDocumentCamera({
11400
+ videoElement: this.video,
11401
+ preferredFacingMode: mode
11777
11402
  });
11778
- this.focusDebug = await this.tryEnableDocumentFocus(chosen.stream);
11779
- this.cameraDebug = debug;
11780
- this.video.addEventListener("loadeddata", () => {
11781
- this.adjustOverlayPosition();
11782
- });
11783
- window.addEventListener("resize", () => this.adjustOverlayPosition());
11403
+ this.currentStream = cameraResult.stream;
11404
+ this.lastCameraInfo = cameraResult;
11405
+ try {
11406
+ const focusResult = await tryEnableDocumentFocus(this.currentStream);
11407
+ console.log("Focus result:", focusResult);
11408
+ } catch (focusError) {
11409
+ console.warn("Could not apply focus settings:", focusError);
11410
+ }
11411
+ if (!this._boundLoadedDataHandler) {
11412
+ this._boundLoadedDataHandler = () => this.adjustOverlayPosition();
11413
+ this.video.addEventListener("loadeddata", this._boundLoadedDataHandler);
11414
+ }
11415
+ if (!this._boundResizeHandler) {
11416
+ this._boundResizeHandler = () => this.adjustOverlayPosition();
11417
+ window.addEventListener("resize", this._boundResizeHandler);
11418
+ }
11784
11419
  setTimeout(() => this.adjustOverlayPosition(), 500);
11785
11420
  this.scanStartTime = Date.now();
11786
11421
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11787
11422
  this.showMessage("Starting scanning");
11423
+ console.log("Chosen camera:", {
11424
+ label: cameraResult.label,
11425
+ deviceId: cameraResult.deviceId,
11426
+ strategy: cameraResult.strategy,
11427
+ settings: cameraResult.settings,
11428
+ score: cameraResult.score
11429
+ });
11788
11430
  return true;
11789
11431
  } catch (error) {
11790
11432
  console.error("startVideo failed:", error);
@@ -11798,26 +11440,47 @@ class ExtractorVideo {
11798
11440
  return false;
11799
11441
  }
11800
11442
  }
11443
+ async stopCurrentStream() {
11444
+ await stopStream(this.currentStream);
11445
+ this.currentStream = null;
11446
+ if (this.video) {
11447
+ this.video.srcObject = null;
11448
+ }
11449
+ await delay(200);
11450
+ }
11801
11451
  stopVideo() {
11802
11452
  this.isRunning = false;
11803
11453
  if (this.video) {
11804
- this.video.pause();
11805
- if (this.video.srcObject !== undefined && this.video.srcObject !== null) {
11806
- this.video.srcObject.getTracks().forEach(t => t.stop());
11454
+ try {
11455
+ this.video.pause();
11456
+ } catch (_) {}
11457
+ if (this.video.srcObject) {
11458
+ try {
11459
+ this.video.srcObject.getTracks().forEach(t => t.stop());
11460
+ } catch (_) {}
11807
11461
  }
11808
11462
  this.video.srcObject = null;
11809
11463
  }
11464
+ if (this.currentStream) {
11465
+ try {
11466
+ this.currentStream.getTracks().forEach(t => t.stop());
11467
+ } catch (_) {}
11468
+ }
11469
+ this.currentStream = null;
11810
11470
  clearTimeout(this.turnMessageTimer);
11811
11471
  this.turnMessageTimer = null;
11812
11472
  }
11813
11473
  adjustOverlayPosition() {
11814
11474
  const video = this.video;
11815
11475
  const dot = document.querySelector(".centerGuideDot");
11816
- if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) return;
11476
+ if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) {
11477
+ return;
11478
+ }
11817
11479
  const videoRatio = video.videoWidth / video.videoHeight;
11818
11480
  const container = video.parentElement;
11819
11481
  const containerRatio = container.clientWidth / container.clientHeight;
11820
- let scaleWidth, scaleHeight;
11482
+ let scaleWidth;
11483
+ let scaleHeight;
11821
11484
  if (videoRatio > containerRatio) {
11822
11485
  scaleWidth = container.clientWidth;
11823
11486
  scaleHeight = container.clientWidth / videoRatio;
@@ -11901,87 +11564,86 @@ class ExtractorVideo {
11901
11564
  }
11902
11565
  </style>
11903
11566
  `;
11904
- } else {
11905
- return `
11906
- <div class="desktopFeedback" id="ScanDocAIMessage"></div>
11907
- <div class="desktopVideoArea">
11908
- <div class="desktopVideoHolder">
11909
- <video id="ScanDocAIVideoElement" class="desktopVideo" autoplay muted playsinline></video>
11910
- <div class="backgroundOverlay"></div>
11911
- <div class="centerGuideDot"></div>
11912
- </div>
11913
- </div>
11914
-
11915
- <style>
11916
- .desktopVideoArea {
11917
- display: flex;
11918
- flex-direction: column;
11919
- gap: 8px;
11920
- padding: 16px;
11921
- background-color: rgba(255, 255, 255, 0.05);
11922
- border: 1px solid ${borderColor};
11923
- border-radius: 15px;
11924
- margin-left: 20%;
11925
- margin-right: 20%;
11926
- }
11927
-
11928
- .desktopVideoHolder {
11929
- position: relative;
11930
- width: 100%;
11931
- overflow: hidden;
11932
- }
11933
-
11934
- .desktopVideo {
11935
- width: 100%;
11936
- height: auto;
11937
- margin-left: auto;
11938
- margin-right: auto;
11939
- max-width: 100vw;
11940
- max-height: 100vh;
11941
- }
11942
-
11943
- .centerGuideDot {
11944
- position: absolute;
11945
- width: 16px;
11946
- height: 16px;
11947
- background-color: #00ff55;
11948
- border-radius: 50%;
11949
- box-shadow: 0 0 8px 2px rgba(0, 255, 100, 0.8);
11950
- z-index: 4;
11951
- animation: flicker 1s infinite;
11952
- pointer-events: none;
11953
- display: none;
11954
- }
11955
-
11956
- @keyframes flicker {
11957
- 0%, 100% { opacity: 1; }
11958
- 50% { opacity: 0.3; }
11959
- }
11960
-
11961
- .backgroundOverlay {
11962
- position: absolute;
11963
- top: 0;
11964
- left: 0;
11965
- width: 100%;
11966
- height: 100%;
11967
- background-color: rgba(0, 0, 0, 0.051);
11968
- z-index: 2;
11969
- pointer-events: none;
11970
- }
11971
-
11972
- .desktopFeedback {
11973
- position: relative;
11974
- display: flex;
11975
- font-size: 22px;
11976
- font-weight: 600;
11977
- min-height: 36px;
11978
- justify-content: center;
11979
- align-items: center;
11980
- color: ${messageColor};
11981
- }
11982
- </style>
11983
- `;
11984
11567
  }
11568
+ return `
11569
+ <div class="desktopFeedback" id="ScanDocAIMessage"></div>
11570
+ <div class="desktopVideoArea">
11571
+ <div class="desktopVideoHolder">
11572
+ <video id="ScanDocAIVideoElement" class="desktopVideo" autoplay muted playsinline></video>
11573
+ <div class="backgroundOverlay"></div>
11574
+ <div class="centerGuideDot"></div>
11575
+ </div>
11576
+ </div>
11577
+
11578
+ <style>
11579
+ .desktopVideoArea {
11580
+ display: flex;
11581
+ flex-direction: column;
11582
+ gap: 8px;
11583
+ padding: 16px;
11584
+ background-color: rgba(255, 255, 255, 0.05);
11585
+ border: 1px solid ${borderColor};
11586
+ border-radius: 15px;
11587
+ margin-left: 20%;
11588
+ margin-right: 20%;
11589
+ }
11590
+
11591
+ .desktopVideoHolder {
11592
+ position: relative;
11593
+ width: 100%;
11594
+ overflow: hidden;
11595
+ }
11596
+
11597
+ .desktopVideo {
11598
+ width: 100%;
11599
+ height: auto;
11600
+ margin-left: auto;
11601
+ margin-right: auto;
11602
+ max-width: 100vw;
11603
+ max-height: 100vh;
11604
+ }
11605
+
11606
+ .centerGuideDot {
11607
+ position: absolute;
11608
+ width: 16px;
11609
+ height: 16px;
11610
+ background-color: #00ff55;
11611
+ border-radius: 50%;
11612
+ box-shadow: 0 0 8px 2px rgba(0, 255, 100, 0.8);
11613
+ z-index: 4;
11614
+ animation: flicker 1s infinite;
11615
+ pointer-events: none;
11616
+ display: none;
11617
+ }
11618
+
11619
+ @keyframes flicker {
11620
+ 0%, 100% { opacity: 1; }
11621
+ 50% { opacity: 0.3; }
11622
+ }
11623
+
11624
+ .backgroundOverlay {
11625
+ position: absolute;
11626
+ top: 0;
11627
+ left: 0;
11628
+ width: 100%;
11629
+ height: 100%;
11630
+ background-color: rgba(0, 0, 0, 0.051);
11631
+ z-index: 2;
11632
+ pointer-events: none;
11633
+ }
11634
+
11635
+ .desktopFeedback {
11636
+ position: relative;
11637
+ display: flex;
11638
+ font-size: 22px;
11639
+ font-weight: 600;
11640
+ min-height: 36px;
11641
+ justify-content: center;
11642
+ align-items: center;
11643
+ color: ${messageColor};
11644
+ }
11645
+ </style>
11646
+ `;
11985
11647
  }
11986
11648
  }
11987
11649
  let EXTRACTION_VIDEO = undefined;
@@ -12011,6 +11673,336 @@ function getExtractionVideo(tokenOrCallback, maybeCallback) {
12011
11673
  }
12012
11674
  return EXTRACTION_VIDEO;
12013
11675
  }
11676
+ function delay(ms) {
11677
+ return new Promise(resolve => setTimeout(resolve, ms));
11678
+ }
11679
+ async function stopStream(stream) {
11680
+ if (!stream) return;
11681
+ for (const track of stream.getTracks()) {
11682
+ try {
11683
+ track.stop();
11684
+ } catch (_) {}
11685
+ }
11686
+ }
11687
+ function getAspectRatio(settings = {}) {
11688
+ if (typeof settings.aspectRatio === "number" && settings.aspectRatio > 0) {
11689
+ return settings.aspectRatio;
11690
+ }
11691
+ if (settings.width && settings.height) {
11692
+ return settings.width / settings.height;
11693
+ }
11694
+ return null;
11695
+ }
11696
+ async function attachStreamToVideo(videoEl, stream) {
11697
+ videoEl.srcObject = stream;
11698
+ try {
11699
+ await videoEl.play();
11700
+ } catch (_) {}
11701
+ }
11702
+ function getSupportedConstraintsSafe() {
11703
+ try {
11704
+ return navigator.mediaDevices?.getSupportedConstraints?.() || {};
11705
+ } catch (_) {
11706
+ return {};
11707
+ }
11708
+ }
11709
+ function buildDocumentScanConstraints(mode = "environment") {
11710
+ const supported = getSupportedConstraintsSafe();
11711
+ const video = {};
11712
+ if (supported.facingMode) {
11713
+ video.facingMode = typeof mode === "string" ? {
11714
+ ideal: mode
11715
+ } : mode || {
11716
+ ideal: "environment"
11717
+ };
11718
+ }
11719
+ if (supported.width) {
11720
+ video.width = {
11721
+ ideal: 1920
11722
+ };
11723
+ }
11724
+ if (supported.height) {
11725
+ video.height = {
11726
+ ideal: 1080
11727
+ };
11728
+ }
11729
+ if (supported.aspectRatio) {
11730
+ video.aspectRatio = {
11731
+ ideal: 16 / 9
11732
+ };
11733
+ }
11734
+ return {
11735
+ video,
11736
+ audio: false
11737
+ };
11738
+ }
11739
+ function buildDeviceConstraints(deviceId) {
11740
+ const supported = getSupportedConstraintsSafe();
11741
+ const video = {
11742
+ deviceId: {
11743
+ exact: deviceId
11744
+ }
11745
+ };
11746
+ if (supported.width) {
11747
+ video.width = {
11748
+ ideal: 1920
11749
+ };
11750
+ }
11751
+ if (supported.height) {
11752
+ video.height = {
11753
+ ideal: 1080
11754
+ };
11755
+ }
11756
+ if (supported.aspectRatio) {
11757
+ video.aspectRatio = {
11758
+ ideal: 16 / 9
11759
+ };
11760
+ }
11761
+ return {
11762
+ video,
11763
+ audio: false
11764
+ };
11765
+ }
11766
+ async function enumerateVideoInputDevices() {
11767
+ if (!navigator.mediaDevices?.enumerateDevices) return [];
11768
+ try {
11769
+ const devices = await navigator.mediaDevices.enumerateDevices();
11770
+ return devices.filter(d => d.kind === "videoinput");
11771
+ } catch (error) {
11772
+ console.warn("enumerateDevices failed:", error);
11773
+ return [];
11774
+ }
11775
+ }
11776
+ function normalizeLabel(label = "") {
11777
+ return String(label).trim().toLowerCase();
11778
+ }
11779
+ function isLikelyRearCamera(label = "") {
11780
+ const v = normalizeLabel(label);
11781
+ return v.includes("back") || v.includes("rear") || v.includes("environment") || v.includes("world");
11782
+ }
11783
+ function isLikelyUltraWideOrMacro(label = "") {
11784
+ const v = normalizeLabel(label);
11785
+ return v.includes("ultra") || v.includes("ultrawide") || v.includes("wide angle") || v.includes("0.5") || v.includes("macro") || v.includes("fisheye");
11786
+ }
11787
+ function scoreCameraCandidate({
11788
+ label,
11789
+ settings
11790
+ }) {
11791
+ const width = Number(settings?.width || 0);
11792
+ const height = Number(settings?.height || 0);
11793
+ const pixels = width * height;
11794
+ const aspectRatio = getAspectRatio(settings) || 0;
11795
+ let score = 0;
11796
+ score += pixels;
11797
+ if (isLikelyRearCamera(label)) {
11798
+ score += 5_000_000;
11799
+ }
11800
+ if (isLikelyUltraWideOrMacro(label)) {
11801
+ score -= 8_000_000;
11802
+ }
11803
+ if (aspectRatio > 1.65 && aspectRatio < 1.85) {
11804
+ score += 750_000;
11805
+ }
11806
+ if (aspectRatio > 2.1) {
11807
+ score -= 2_000_000;
11808
+ }
11809
+ if (width >= 1920) {
11810
+ score += 500_000;
11811
+ }
11812
+ if (width < 1280 || height < 720) {
11813
+ score -= 3_000_000;
11814
+ }
11815
+ return score;
11816
+ }
11817
+ async function probeCameraCandidate(device, videoElement) {
11818
+ let stream = null;
11819
+ try {
11820
+ stream = await navigator.mediaDevices.getUserMedia(buildDeviceConstraints(device.deviceId));
11821
+ await attachStreamToVideo(videoElement, stream);
11822
+ await waitForVideoReady(videoElement, 800);
11823
+ const track = stream.getVideoTracks?.()[0];
11824
+ const settings = track?.getSettings?.() || {};
11825
+ return {
11826
+ ok: true,
11827
+ device,
11828
+ stream,
11829
+ label: track?.label || device.label || "",
11830
+ deviceId: settings.deviceId || device.deviceId,
11831
+ settings,
11832
+ aspectRatio: getAspectRatio(settings),
11833
+ score: scoreCameraCandidate({
11834
+ label: track?.label || device.label || "",
11835
+ settings
11836
+ })
11837
+ };
11838
+ } catch (error) {
11839
+ if (stream) {
11840
+ await stopStream(stream);
11841
+ }
11842
+ return {
11843
+ ok: false,
11844
+ device,
11845
+ error,
11846
+ score: Number.NEGATIVE_INFINITY
11847
+ };
11848
+ }
11849
+ }
11850
+ function waitForVideoReady(videoEl, timeoutMs = 800) {
11851
+ return new Promise(resolve => {
11852
+ if (videoEl.readyState >= 2 && videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
11853
+ resolve();
11854
+ return;
11855
+ }
11856
+ let done = false;
11857
+ const finish = () => {
11858
+ if (done) return;
11859
+ done = true;
11860
+ videoEl.removeEventListener("loadeddata", onReady);
11861
+ videoEl.removeEventListener("canplay", onReady);
11862
+ clearTimeout(timer);
11863
+ resolve();
11864
+ };
11865
+ const onReady = () => finish();
11866
+ const timer = setTimeout(finish, timeoutMs);
11867
+ videoEl.addEventListener("loadeddata", onReady, {
11868
+ once: true
11869
+ });
11870
+ videoEl.addEventListener("canplay", onReady, {
11871
+ once: true
11872
+ });
11873
+ });
11874
+ }
11875
+ async function tryEnableDocumentFocus(stream) {
11876
+ const track = stream?.getVideoTracks?.()[0];
11877
+ if (!track) {
11878
+ return {
11879
+ ok: false,
11880
+ reason: "No video track found"
11881
+ };
11882
+ }
11883
+ if (!track.getCapabilities || !track.applyConstraints) {
11884
+ return {
11885
+ ok: false,
11886
+ reason: "Focus APIs unavailable on this browser/device",
11887
+ settingsBefore: track.getSettings ? track.getSettings() : null
11888
+ };
11889
+ }
11890
+ const caps = track.getCapabilities();
11891
+ const result = {
11892
+ ok: true,
11893
+ capabilities: caps,
11894
+ attempts: [],
11895
+ settingsBefore: track.getSettings ? track.getSettings() : null
11896
+ };
11897
+ try {
11898
+ let applied = false;
11899
+ if (Array.isArray(caps.focusMode) && caps.focusMode.includes("continuous")) {
11900
+ await track.applyConstraints({
11901
+ advanced: [{
11902
+ focusMode: "continuous"
11903
+ }]
11904
+ });
11905
+ result.attempts.push("Applied focusMode=continuous");
11906
+ applied = true;
11907
+ }
11908
+ if (caps.focusDistance) {
11909
+ const min = caps.focusDistance.min ?? 0;
11910
+ const max = caps.focusDistance.max ?? 0;
11911
+ const docDistance = min + (max - min) * 0.3;
11912
+ const advanced = [];
11913
+ if (Array.isArray(caps.focusMode) && caps.focusMode.includes("manual")) {
11914
+ advanced.push({
11915
+ focusMode: "manual"
11916
+ });
11917
+ }
11918
+ advanced.push({
11919
+ focusDistance: docDistance
11920
+ });
11921
+ await track.applyConstraints({
11922
+ advanced
11923
+ });
11924
+ result.attempts.push(`Applied focusDistance=${docDistance}`);
11925
+ applied = true;
11926
+ }
11927
+ if (!applied) {
11928
+ result.ok = false;
11929
+ result.reason = "No supported focus controls were exposed";
11930
+ }
11931
+ result.settingsAfter = track.getSettings ? track.getSettings() : null;
11932
+ return result;
11933
+ } catch (err) {
11934
+ result.ok = false;
11935
+ result.error = err.message || String(err);
11936
+ result.settingsAfter = track.getSettings ? track.getSettings() : null;
11937
+ return result;
11938
+ }
11939
+ }
11940
+ async function openBestDocumentCamera({
11941
+ videoElement,
11942
+ preferredFacingMode = "environment"
11943
+ }) {
11944
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
11945
+ throw new Error("getUserMedia is not available");
11946
+ }
11947
+ if (!window.isSecureContext) {
11948
+ throw new Error("Camera requires HTTPS / secure context");
11949
+ }
11950
+ let bootstrapStream = null;
11951
+ try {
11952
+ bootstrapStream = await navigator.mediaDevices.getUserMedia(buildDocumentScanConstraints(preferredFacingMode));
11953
+ await attachStreamToVideo(videoElement, bootstrapStream);
11954
+ await waitForVideoReady(videoElement, 800);
11955
+ } catch (preferredError) {
11956
+ console.warn("Preferred camera open failed, falling back:", preferredError);
11957
+ bootstrapStream = await navigator.mediaDevices.getUserMedia({
11958
+ video: true,
11959
+ audio: false
11960
+ });
11961
+ await attachStreamToVideo(videoElement, bootstrapStream);
11962
+ await waitForVideoReady(videoElement, 800);
11963
+ }
11964
+ const bootstrapTrack = bootstrapStream.getVideoTracks?.()[0];
11965
+ const bootstrapSettings = bootstrapTrack?.getSettings?.() || {};
11966
+ const bootstrapLabel = bootstrapTrack?.label || "";
11967
+ const bootstrapDeviceId = bootstrapSettings.deviceId || null;
11968
+ let best = {
11969
+ ok: true,
11970
+ device: null,
11971
+ stream: bootstrapStream,
11972
+ label: bootstrapLabel,
11973
+ deviceId: bootstrapDeviceId,
11974
+ settings: bootstrapSettings,
11975
+ aspectRatio: getAspectRatio(bootstrapSettings),
11976
+ score: scoreCameraCandidate({
11977
+ label: bootstrapLabel,
11978
+ settings: bootstrapSettings
11979
+ }),
11980
+ strategy: "bootstrap"
11981
+ };
11982
+ const devices = await enumerateVideoInputDevices();
11983
+ if (devices.length <= 1) {
11984
+ return best;
11985
+ }
11986
+ const rearDevices = devices.filter(d => isLikelyRearCamera(d.label));
11987
+ const candidates = rearDevices.length > 0 ? rearDevices : devices;
11988
+ for (const device of candidates) {
11989
+ if (!device.deviceId) continue;
11990
+ if (device.deviceId === bootstrapDeviceId) continue;
11991
+ const probed = await probeCameraCandidate(device, videoElement);
11992
+ if (!probed.ok) continue;
11993
+ if (probed.score > best.score) {
11994
+ await stopStream(best.stream);
11995
+ best = {
11996
+ ...probed,
11997
+ strategy: "probed-better-camera"
11998
+ };
11999
+ } else {
12000
+ await stopStream(probed.stream);
12001
+ }
12002
+ }
12003
+ await attachStreamToVideo(videoElement, best.stream);
12004
+ return best;
12005
+ }
12014
12006
 
12015
12007
  /***/ }),
12016
12008
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "scandoc-ai-components",
3
3
  "author": "ScanDoc-AI",
4
- "version": "0.1.17",
4
+ "version": "0.1.19",
5
5
  "private": false,
6
6
  "description": "Pure JavaScript package for integrating ScanDoc-AI services.",
7
7
  "keywords": [