scandoc-ai-components 0.1.18 → 0.1.20

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 +505 -618
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11170,25 +11170,15 @@ __webpack_require__.r(__webpack_exports__);
11170
11170
 
11171
11171
  const DOCUMENT_DETECTOR = new _ai_detect_document__WEBPACK_IMPORTED_MODULE_0__["default"]();
11172
11172
  class ExtractorVideo {
11173
- static DEBUG = true;
11174
11173
  static FREQUENCY_MS = 10;
11175
11174
  static VALIDATION_BATCH_SIZE = 1;
11176
11175
  static VALIDATION_IMG_WIDTH = 384;
11177
11176
  static VALIDATION_IMG_HEIGHT = 384;
11178
- static VIDEO_CANDIDATE_SETTINGS = Object.freeze([{
11179
- width: 1920,
11180
- height: 1080
11181
- }, {
11182
- width: 1280,
11183
- height: 960
11184
- }, {
11185
- width: 1280,
11186
- height: 720
11187
- }]);
11188
11177
  constructor(onExtractedResults) {
11189
11178
  this.candidateImages = [];
11190
11179
  this.extractionImages = {};
11191
11180
  this.onExtractedResults = onExtractedResults;
11181
+ this.onCameraSelected = null;
11192
11182
  this.pastBlurValues = [];
11193
11183
  this.isRunning = false;
11194
11184
  this.scanStartTime = null;
@@ -11198,47 +11188,8 @@ class ExtractorVideo {
11198
11188
  this.waitingForSecondSide = false;
11199
11189
  this.hasShownTurnMessage = false;
11200
11190
  this.turnMessageTimer = null;
11201
- this.debugLog = [];
11202
- this.onExtractedResults = onExtractedResults;
11203
- }
11204
- logDebug(event, data = {}) {
11205
- if (!ExtractorVideo.DEBUG) return;
11206
- const entry = {
11207
- ts: new Date().toISOString(),
11208
- event,
11209
- data
11210
- };
11211
- this.debugLog.push(entry);
11212
- try {
11213
- console.log("[ScanDocAI]", event, data);
11214
- } catch (_) {}
11215
- }
11216
- getDebugDump() {
11217
- return {
11218
- userAgent: navigator.userAgent,
11219
- secureContext: window.isSecureContext,
11220
- location: window.location.href,
11221
- videoPresent: !!this.video,
11222
- videoReadyState: this.video?.readyState,
11223
- videoNetworkState: this.video?.networkState,
11224
- videoPaused: this.video?.paused,
11225
- videoMuted: this.video?.muted,
11226
- videoAutoplay: this.video?.autoplay,
11227
- videoPlaysInline: this.video?.playsInline,
11228
- videoDimensions: {
11229
- videoWidth: this.video?.videoWidth || 0,
11230
- videoHeight: this.video?.videoHeight || 0,
11231
- clientWidth: this.video?.clientWidth || 0,
11232
- clientHeight: this.video?.clientHeight || 0
11233
- },
11234
- streamTracks: this.video?.srcObject ? this.video.srcObject.getTracks().map(t => ({
11235
- kind: t.kind,
11236
- label: t.label,
11237
- readyState: t.readyState,
11238
- enabled: t.enabled
11239
- })) : [],
11240
- log: this.debugLog
11241
- };
11191
+ this.currentStream = null;
11192
+ this.lastCameraInfo = null;
11242
11193
  }
11243
11194
  reset() {
11244
11195
  this.stopVideo();
@@ -11254,18 +11205,32 @@ class ExtractorVideo {
11254
11205
  this.lastMessage = null;
11255
11206
  this.waitingForSecondSide = false;
11256
11207
  this.hasShownTurnMessage = false;
11208
+ this.lastCameraInfo = null;
11257
11209
  clearTimeout(this.turnMessageTimer);
11258
11210
  this.turnMessageTimer = null;
11259
- this.debugLog = [];
11211
+ }
11212
+ emitCameraInfo(cameraInfo) {
11213
+ this.lastCameraInfo = cameraInfo;
11214
+ try {
11215
+ if (typeof this.onCameraSelected === "function") {
11216
+ this.onCameraSelected(cameraInfo);
11217
+ }
11218
+ } catch (err) {
11219
+ console.warn("onCameraSelected callback failed:", err);
11220
+ }
11221
+ try {
11222
+ window.dispatchEvent(new CustomEvent("ScanDocAICameraSelected", {
11223
+ detail: cameraInfo
11224
+ }));
11225
+ } catch (err) {
11226
+ console.warn("Could not dispatch ScanDocAICameraSelected event:", err);
11227
+ }
11260
11228
  }
11261
11229
  async analyzeVideoStream() {
11262
11230
  if (!this.isRunning) return;
11263
11231
  const now = Date.now();
11264
11232
  const cfgValues = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfigValues)();
11265
11233
  if (this.scanStartTime && now - this.scanStartTime > cfgValues.MAX_SCAN_DURATION_MS) {
11266
- this.logDebug("scan_timeout", {
11267
- elapsedMs: now - this.scanStartTime
11268
- });
11269
11234
  this.stopVideo();
11270
11235
  this.onExtractedResults({
11271
11236
  success: false,
@@ -11276,16 +11241,7 @@ class ExtractorVideo {
11276
11241
  }
11277
11242
  const canvas = document.createElement("canvas");
11278
11243
  const video = document.getElementById("ScanDocAIVideoElement");
11279
- if (!video) {
11280
- this.logDebug("analyze_no_video_element");
11281
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11282
- return;
11283
- }
11284
- if (video.videoWidth < video.videoHeight) {
11285
- this.logDebug("portrait_video_detected", {
11286
- videoWidth: video.videoWidth,
11287
- videoHeight: video.videoHeight
11288
- });
11244
+ if (video && video.videoWidth < video.videoHeight) {
11289
11245
  this.showMessage("Please rotate your device to landscape mode.");
11290
11246
  this.candidateImages = [];
11291
11247
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
@@ -11304,9 +11260,6 @@ class ExtractorVideo {
11304
11260
  const [isValidationOk, response] = await (0,_requests_validation__WEBPACK_IMPORTED_MODULE_3__["default"])(images.map(e => e.validationImg), this.pastBlurValues, {});
11305
11261
  if (!isValidationOk) {
11306
11262
  const msg = Array.isArray(response) ? response.join(", ") : String(response || "");
11307
- this.logDebug("validation_failed", {
11308
- response: msg
11309
- });
11310
11263
  if (msg.toLowerCase().includes("token expired/invalid")) {
11311
11264
  this.stopVideo();
11312
11265
  this.showMessage("Token expired/invalid. Please provide a new token.", true);
@@ -11321,7 +11274,6 @@ class ExtractorVideo {
11321
11274
  return;
11322
11275
  }
11323
11276
  if (response?.InfoCode === "1006" && response?.TransactionID) {
11324
- this.logDebug("unsupported_document", response);
11325
11277
  const frontImage = this.candidateImages[this.candidateImages.length - 1].fullImg;
11326
11278
  await (0,_requests_error_logging__WEBPACK_IMPORTED_MODULE_4__["default"])(frontImage, "", response.TransactionID, response.OS, response.Device, response.Browser);
11327
11279
  this.showMessage("Document not supported.", true);
@@ -11403,10 +11355,6 @@ class ExtractorVideo {
11403
11355
  return;
11404
11356
  }
11405
11357
  this.candidateImages = [];
11406
- }).catch(err => {
11407
- this.logDebug("document_detector_error", {
11408
- message: err?.message || String(err)
11409
- });
11410
11358
  }).finally(() => {
11411
11359
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11412
11360
  });
@@ -11427,7 +11375,7 @@ class ExtractorVideo {
11427
11375
  this.turnMessageTimer = null;
11428
11376
  this.stopVideo();
11429
11377
  if (isExtractionOk) {
11430
- this.showMessage("Success - data extracted", "success");
11378
+ this.showMessage("Success - data extracted", true);
11431
11379
  const shouldStop = this.onExtractedResults({
11432
11380
  success: true,
11433
11381
  code: "001",
@@ -11435,7 +11383,7 @@ class ExtractorVideo {
11435
11383
  data: extractionData
11436
11384
  });
11437
11385
  if (shouldStop === true) {
11438
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11386
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11439
11387
  }
11440
11388
  } else {
11441
11389
  this.onExtractedResults({
@@ -11443,509 +11391,89 @@ class ExtractorVideo {
11443
11391
  code: "005",
11444
11392
  info: "Document validation passed but extraction failed."
11445
11393
  });
11446
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11447
- }
11448
- }
11449
- getSupportedConstraintsSafe() {
11450
- if (!navigator.mediaDevices?.getSupportedConstraints) return {};
11451
- try {
11452
- return navigator.mediaDevices.getSupportedConstraints();
11453
- } catch (_) {
11454
- return {};
11455
- }
11456
- }
11457
- getAspectRatio(settings) {
11458
- if (typeof settings?.aspectRatio === "number" && settings.aspectRatio > 0) {
11459
- return settings.aspectRatio;
11460
- }
11461
- if (settings?.width && settings?.height) {
11462
- return settings.width / settings.height;
11463
- }
11464
- return null;
11465
- }
11466
- isLandscape(settings) {
11467
- return (settings?.width || 0) > (settings?.height || 0);
11468
- }
11469
- isFrontLabel(label) {
11470
- const l = (label || "").toLowerCase();
11471
- return l.includes("front") || l.includes("selfie") || l.includes("user facing") || l.includes("facing front");
11472
- }
11473
- isRearLikeLabel(label) {
11474
- const l = (label || "").toLowerCase();
11475
- return l.includes("rear") || l.includes("back") || l.includes("environment") || l.includes("facing back") || l.includes("facing rear");
11476
- }
11477
- scoreTrack({
11478
- settings,
11479
- label
11480
- }) {
11481
- const width = settings?.width || 0;
11482
- const height = settings?.height || 0;
11483
- const pixels = width * height;
11484
- const aspectRatio = this.getAspectRatio(settings) || 0;
11485
- let score = pixels;
11486
- if (width > height) score += 1_000_000_000;
11487
- if (aspectRatio >= 1.3) score += 100_000_000;
11488
- if (this.isRearLikeLabel(label)) score += 10_000_000;
11489
- if (this.isFrontLabel(label)) score -= 10_000_000_000;
11490
- return score;
11491
- }
11492
- buildVideoConstraints({
11493
- width,
11494
- height,
11495
- mode,
11496
- deviceId
11497
- }) {
11498
- const supported = this.getSupportedConstraintsSafe();
11499
- const video = {};
11500
- if (deviceId) {
11501
- video.deviceId = {
11502
- exact: deviceId
11503
- };
11504
- } else if (supported.facingMode) {
11505
- if (typeof mode === "string") {
11506
- video.facingMode = {
11507
- ideal: mode
11508
- };
11509
- } else if (mode && typeof mode === "object") {
11510
- video.facingMode = mode;
11511
- } else {
11512
- video.facingMode = {
11513
- ideal: "environment"
11514
- };
11515
- }
11516
- }
11517
- if (supported.width) video.width = {
11518
- ideal: width
11519
- };
11520
- if (supported.height) video.height = {
11521
- ideal: height
11522
- };
11523
- if (supported.aspectRatio) video.aspectRatio = {
11524
- ideal: width / height
11525
- };
11526
- return {
11527
- video,
11528
- audio: false
11529
- };
11530
- }
11531
- async pauseBetweenCameraOps(ms = 180) {
11532
- await new Promise(resolve => setTimeout(resolve, ms));
11533
- }
11534
- async stopMediaStream(stream) {
11535
- if (!stream) return;
11536
- try {
11537
- stream.getTracks().forEach(t => t.stop());
11538
- } catch (_) {}
11539
- await this.pauseBetweenCameraOps();
11540
- }
11541
- async tryEnableDocumentFocus(stream) {
11542
- const track = stream?.getVideoTracks?.()[0];
11543
- if (!track) {
11544
- this.logDebug("focus_no_track");
11545
- return {
11546
- ok: false,
11547
- reason: "No video track found"
11548
- };
11549
- }
11550
- if (!track.getCapabilities || !track.applyConstraints) {
11551
- this.logDebug("focus_api_unavailable");
11552
- return {
11553
- ok: false,
11554
- reason: "Focus APIs unavailable on this browser/device",
11555
- settingsBefore: track.getSettings ? track.getSettings() : null
11556
- };
11394
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11557
11395
  }
11558
- const caps = track.getCapabilities();
11559
- const result = {
11560
- ok: true,
11561
- capabilities: caps,
11562
- attempts: [],
11563
- settingsBefore: track.getSettings ? track.getSettings() : null
11564
- };
11565
- try {
11566
- let applied = false;
11567
- if (Array.isArray(caps.focusMode) && caps.focusMode.includes("continuous")) {
11568
- await track.applyConstraints({
11569
- advanced: [{
11570
- focusMode: "continuous"
11571
- }]
11572
- });
11573
- result.attempts.push("Applied focusMode=continuous");
11574
- applied = true;
11575
- }
11576
- if (caps.focusDistance) {
11577
- const min = caps.focusDistance.min;
11578
- const max = caps.focusDistance.max;
11579
- const docDistance = min + (max - min) * 0.30;
11580
- const advanced = [];
11581
- if (Array.isArray(caps.focusMode) && caps.focusMode.includes("manual")) {
11582
- advanced.push({
11583
- focusMode: "manual"
11584
- });
11585
- }
11586
- advanced.push({
11587
- focusDistance: docDistance
11588
- });
11589
- await track.applyConstraints({
11590
- advanced
11591
- });
11592
- result.attempts.push(`Applied focusDistance=${docDistance}`);
11593
- applied = true;
11594
- }
11595
- if (!applied) {
11596
- result.ok = false;
11597
- result.reason = "No supported focus controls were exposed";
11598
- }
11599
- result.settingsAfter = track.getSettings ? track.getSettings() : null;
11600
- this.logDebug("focus_result", result);
11601
- return result;
11602
- } catch (err) {
11603
- result.ok = false;
11604
- result.error = err.message || String(err);
11605
- result.settingsAfter = track.getSettings ? track.getSettings() : null;
11606
- this.logDebug("focus_error", result);
11607
- return result;
11608
- }
11609
- }
11610
- async openCandidateStream({
11611
- mode,
11612
- deviceId,
11613
- width,
11614
- height
11615
- }) {
11616
- const constraints = this.buildVideoConstraints({
11617
- width,
11618
- height,
11619
- mode,
11620
- deviceId
11621
- });
11622
- this.logDebug("getUserMedia_attempt", {
11623
- constraints
11624
- });
11625
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
11626
- this.logDebug("getUserMedia_success", {
11627
- deviceId,
11628
- width,
11629
- height,
11630
- trackLabels: stream.getTracks().map(t => t.label)
11631
- });
11632
- return stream;
11633
- }
11634
- async testDeviceAtPreferredResolutions(device, mode) {
11635
- const attempts = [];
11636
- for (const size of ExtractorVideo.VIDEO_CANDIDATE_SETTINGS) {
11637
- let stream = null;
11638
- try {
11639
- stream = await this.openCandidateStream({
11640
- mode,
11641
- deviceId: device?.deviceId,
11642
- width: size.width,
11643
- height: size.height
11644
- });
11645
- const track = stream.getVideoTracks()[0];
11646
- const settings = track.getSettings();
11647
- const label = track.label || device?.label || "";
11648
- const aspectRatio = this.getAspectRatio(settings);
11649
- const result = {
11650
- stream,
11651
- label,
11652
- settings,
11653
- aspectRatio,
11654
- landscape: this.isLandscape(settings),
11655
- score: this.scoreTrack({
11656
- settings,
11657
- label
11658
- }),
11659
- requested: size
11660
- };
11661
- attempts.push({
11662
- ok: true,
11663
- requested: size,
11664
- returnedWidth: settings.width,
11665
- returnedHeight: settings.height,
11666
- aspectRatio,
11667
- label
11668
- });
11669
- this.logDebug("device_test_success", {
11670
- deviceLabel: device?.label,
11671
- deviceId: device?.deviceId,
11672
- requested: size,
11673
- settings,
11674
- label
11675
- });
11676
- return {
11677
- result,
11678
- attempts
11679
- };
11680
- } catch (err) {
11681
- attempts.push({
11682
- ok: false,
11683
- requested: size,
11684
- error: err.message || String(err)
11685
- });
11686
- this.logDebug("device_test_error", {
11687
- deviceLabel: device?.label,
11688
- deviceId: device?.deviceId,
11689
- requested: size,
11690
- error: err?.message || String(err),
11691
- name: err?.name
11692
- });
11693
- await this.stopMediaStream(stream);
11694
- }
11695
- }
11696
- return {
11697
- result: null,
11698
- attempts
11699
- };
11700
- }
11701
- async pickBestPhoneCamera(mode = "environment") {
11702
- const debug = {
11703
- mode,
11704
- tested: []
11705
- };
11706
- let warmupStream = null;
11707
- let warmupResult = null;
11708
- try {
11709
- const warmupSize = ExtractorVideo.VIDEO_CANDIDATE_SETTINGS[2];
11710
- warmupStream = await this.openCandidateStream({
11711
- mode,
11712
- width: warmupSize.width,
11713
- height: warmupSize.height
11714
- });
11715
- const track = warmupStream.getVideoTracks()[0];
11716
- const settings = track.getSettings();
11717
- const label = track.label || "";
11718
- warmupResult = {
11719
- stream: warmupStream,
11720
- label,
11721
- settings,
11722
- aspectRatio: this.getAspectRatio(settings),
11723
- landscape: this.isLandscape(settings),
11724
- score: this.scoreTrack({
11725
- settings,
11726
- label
11727
- }),
11728
- requested: warmupSize
11729
- };
11730
- this.logDebug("warmup_success", {
11731
- label,
11732
- settings
11733
- });
11734
- } catch (err) {
11735
- this.logDebug("warmup_error", {
11736
- name: err?.name,
11737
- message: err?.message || String(err)
11738
- });
11739
- warmupStream = null;
11740
- }
11741
- let devices = [];
11742
- try {
11743
- devices = await navigator.mediaDevices.enumerateDevices();
11744
- this.logDebug("enumerate_devices", {
11745
- devices: devices.map(d => ({
11746
- kind: d.kind,
11747
- label: d.label,
11748
- deviceId: d.deviceId
11749
- }))
11750
- });
11751
- } catch (err) {
11752
- this.logDebug("enumerate_devices_error", {
11753
- name: err?.name,
11754
- message: err?.message || String(err)
11755
- });
11756
- devices = [];
11757
- }
11758
- const videoInputs = devices.filter(d => d.kind === "videoinput");
11759
- const candidateDevices = mode === "environment" ? videoInputs.filter(d => !this.isFrontLabel(d.label)) : videoInputs;
11760
- if (warmupStream) {
11761
- await this.stopMediaStream(warmupStream);
11762
- warmupStream = null;
11763
- }
11764
- const usableResults = [];
11765
- for (let i = 0; i < candidateDevices.length; i += 1) {
11766
- const device = candidateDevices[i];
11767
- const {
11768
- result,
11769
- attempts
11770
- } = await this.testDeviceAtPreferredResolutions(device, mode);
11771
- debug.tested.push({
11772
- deviceId: device.deviceId,
11773
- label: device.label,
11774
- attempts,
11775
- final: result ? {
11776
- label: result.label,
11777
- settings: result.settings,
11778
- aspectRatio: result.aspectRatio,
11779
- landscape: result.landscape,
11780
- score: result.score,
11781
- requested: result.requested
11782
- } : null
11783
- });
11784
- if (result) {
11785
- const frontRejected = mode === "environment" && this.isFrontLabel(result.label);
11786
- const geometryOk = result.landscape;
11787
- if (!frontRejected && geometryOk) {
11788
- usableResults.push(result);
11789
- } else {
11790
- await this.stopMediaStream(result.stream);
11791
- }
11792
- }
11793
- }
11794
- if (usableResults.length > 0) {
11795
- usableResults.sort((a, b) => b.score - a.score);
11796
- const chosen = usableResults[0];
11797
- for (const candidate of usableResults) {
11798
- if (candidate !== chosen) {
11799
- await this.stopMediaStream(candidate.stream);
11800
- }
11801
- }
11802
- debug.chosen = {
11803
- label: chosen.label,
11804
- settings: chosen.settings,
11805
- aspectRatio: chosen.aspectRatio,
11806
- requested: chosen.requested,
11807
- source: "device-test"
11808
- };
11809
- this.logDebug("camera_chosen", debug.chosen);
11810
- return {
11811
- chosen,
11812
- debug
11813
- };
11814
- }
11815
- for (const size of ExtractorVideo.VIDEO_CANDIDATE_SETTINGS) {
11816
- try {
11817
- const stream = await this.openCandidateStream({
11818
- mode,
11819
- width: size.width,
11820
- height: size.height
11821
- });
11822
- const track = stream.getVideoTracks()[0];
11823
- const settings = track.getSettings();
11824
- const label = track.label || "";
11825
- const aspectRatio = this.getAspectRatio(settings);
11826
- const landscape = this.isLandscape(settings);
11827
- if (mode === "environment" && !landscape) {
11828
- await this.stopMediaStream(stream);
11829
- continue;
11830
- }
11831
- const chosen = {
11832
- stream,
11833
- label,
11834
- settings,
11835
- aspectRatio,
11836
- landscape,
11837
- score: this.scoreTrack({
11838
- settings,
11839
- label
11840
- }),
11841
- requested: size
11842
- };
11843
- debug.chosen = {
11844
- label,
11845
- settings,
11846
- aspectRatio,
11847
- requested: size,
11848
- source: "fallback-facingMode"
11849
- };
11850
- this.logDebug("camera_chosen_fallback", debug.chosen);
11851
- return {
11852
- chosen,
11853
- debug
11854
- };
11855
- } catch (err) {
11856
- this.logDebug("fallback_open_error", {
11857
- requested: size,
11858
- name: err?.name,
11859
- message: err?.message || String(err)
11860
- });
11861
- }
11862
- }
11863
- throw new Error("Could not start a suitable camera.");
11864
11396
  }
11865
11397
  async startVideo() {
11866
11398
  try {
11867
- this.logDebug("startVideo_begin");
11868
11399
  const serviceConfig = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfig)();
11869
11400
  await serviceConfig.getAccessToken(false);
11870
11401
  const videoElem = document.getElementById("ScanDocAIVideoElement");
11871
11402
  if (!videoElem) {
11872
11403
  throw new Error("Video element not found.");
11873
11404
  }
11405
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
11406
+ throw new Error("getUserMedia is not available");
11407
+ }
11408
+ if (!window.isSecureContext) {
11409
+ throw new Error("Camera requires HTTPS / secure context");
11410
+ }
11874
11411
  this.video = videoElem;
11875
11412
  this.isRunning = true;
11876
11413
  this.scanStartTime = Date.now();
11877
11414
  const cfgValues = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfigValues)();
11878
11415
  const mode = cfgValues.VIDEO_FACING_MODE ?? "environment";
11879
- const {
11880
- chosen,
11881
- debug
11882
- } = await this.pickBestPhoneCamera(mode);
11883
- this.video.srcObject = chosen.stream;
11884
- this.logDebug("srcObject_assigned", {
11885
- label: chosen.label,
11886
- settings: chosen.settings
11416
+ await this.stopCurrentStream();
11417
+ const cameraResult = await openBestDocumentCamera({
11418
+ videoElement: this.video,
11419
+ preferredFacingMode: mode
11887
11420
  });
11421
+ this.currentStream = cameraResult.stream;
11422
+ this.lastCameraInfo = cameraResult;
11423
+ this.emitCameraInfo(cameraResult);
11888
11424
  try {
11889
- await this.video.play();
11890
- this.logDebug("video_play_success", {
11891
- readyState: this.video.readyState,
11892
- videoWidth: this.video.videoWidth,
11893
- videoHeight: this.video.videoHeight
11894
- });
11895
- } catch (e) {
11896
- this.logDebug("video_play_error", {
11897
- name: e?.name,
11898
- message: e?.message || String(e)
11899
- });
11900
- throw e;
11425
+ const focusResult = await tryEnableDocumentFocus(this.currentStream);
11426
+ console.log("Focus result:", focusResult);
11427
+ } catch (focusError) {
11428
+ console.warn("Could not apply focus settings:", focusError);
11429
+ }
11430
+ if (!this._boundLoadedDataHandler) {
11431
+ this._boundLoadedDataHandler = () => this.adjustOverlayPosition();
11432
+ this.video.addEventListener("loadeddata", this._boundLoadedDataHandler);
11433
+ }
11434
+ if (!this._boundResizeHandler) {
11435
+ this._boundResizeHandler = () => this.adjustOverlayPosition();
11436
+ window.addEventListener("resize", this._boundResizeHandler);
11901
11437
  }
11902
- this.focusDebug = await this.tryEnableDocumentFocus(chosen.stream);
11903
- this.cameraDebug = debug;
11904
- this.video.addEventListener("loadeddata", () => {
11905
- this.logDebug("video_loadeddata", {
11906
- videoWidth: this.video.videoWidth,
11907
- videoHeight: this.video.videoHeight
11908
- });
11909
- this.adjustOverlayPosition();
11910
- });
11911
- this.video.addEventListener("error", () => {
11912
- this.logDebug("video_element_error", {
11913
- error: this.video?.error ? {
11914
- code: this.video.error.code,
11915
- message: this.video.error.message
11916
- } : null
11917
- });
11918
- });
11919
- window.addEventListener("resize", () => this.adjustOverlayPosition());
11920
11438
  setTimeout(() => this.adjustOverlayPosition(), 500);
11921
11439
  this.scanStartTime = Date.now();
11922
11440
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11923
11441
  this.showMessage("Starting scanning");
11924
- this.logDebug("startVideo_success", this.getDebugDump());
11442
+ console.log("Chosen camera:", {
11443
+ label: cameraResult.label,
11444
+ deviceId: cameraResult.deviceId,
11445
+ strategy: cameraResult.strategy,
11446
+ settings: cameraResult.settings,
11447
+ score: cameraResult.score,
11448
+ tested: cameraResult.tested
11449
+ });
11925
11450
  return true;
11926
11451
  } catch (error) {
11927
11452
  console.error("startVideo failed:", error);
11928
- this.logDebug("startVideo_failed", {
11929
- name: error?.name,
11930
- message: error?.message || String(error),
11931
- dump: this.getDebugDump()
11932
- });
11933
11453
  this.isRunning = false;
11934
11454
  this.stopVideo();
11935
11455
  this.onExtractedResults({
11936
11456
  success: false,
11937
11457
  code: error.status === 401 || error.status === 403 ? "004" : "005",
11938
- info: error.status === 401 || error.status === 403 ? "Authentication failed: Invalid API key/token." : "Startup failed: " + (error.message || "Unknown error"),
11939
- debug: ExtractorVideo.DEBUG ? this.getDebugDump() : undefined
11458
+ info: error.status === 401 || error.status === 403 ? "Authentication failed: Invalid API key/token." : "Startup failed: " + (error.message || "Unknown error")
11940
11459
  });
11941
11460
  return false;
11942
11461
  }
11943
11462
  }
11463
+ async stopCurrentStream() {
11464
+ await stopStream(this.currentStream);
11465
+ this.currentStream = null;
11466
+ if (this.video) {
11467
+ this.video.srcObject = null;
11468
+ }
11469
+ await delay(200);
11470
+ }
11944
11471
  stopVideo() {
11945
11472
  this.isRunning = false;
11946
- this.logDebug("stopVideo_called");
11947
11473
  if (this.video) {
11948
- this.video.pause();
11474
+ try {
11475
+ this.video.pause();
11476
+ } catch (_) {}
11949
11477
  if (this.video.srcObject !== undefined && this.video.srcObject !== null) {
11950
11478
  try {
11951
11479
  this.video.srcObject.getTracks().forEach(t => t.stop());
@@ -11953,17 +11481,26 @@ class ExtractorVideo {
11953
11481
  }
11954
11482
  this.video.srcObject = null;
11955
11483
  }
11484
+ if (this.currentStream) {
11485
+ try {
11486
+ this.currentStream.getTracks().forEach(t => t.stop());
11487
+ } catch (_) {}
11488
+ }
11489
+ this.currentStream = null;
11956
11490
  clearTimeout(this.turnMessageTimer);
11957
11491
  this.turnMessageTimer = null;
11958
11492
  }
11959
11493
  adjustOverlayPosition() {
11960
11494
  const video = this.video;
11961
11495
  const dot = document.querySelector(".centerGuideDot");
11962
- if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) return;
11496
+ if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) {
11497
+ return;
11498
+ }
11963
11499
  const videoRatio = video.videoWidth / video.videoHeight;
11964
11500
  const container = video.parentElement;
11965
11501
  const containerRatio = container.clientWidth / container.clientHeight;
11966
- let scaleWidth, scaleHeight;
11502
+ let scaleWidth;
11503
+ let scaleHeight;
11967
11504
  if (videoRatio > containerRatio) {
11968
11505
  scaleWidth = container.clientWidth;
11969
11506
  scaleHeight = container.clientWidth / videoRatio;
@@ -12047,81 +11584,98 @@ class ExtractorVideo {
12047
11584
  }
12048
11585
  </style>
12049
11586
  `;
12050
- } else {
12051
- return `
12052
- <div class="desktopFeedback" id="ScanDocAIMessage"></div>
12053
- <div class="desktopVideoArea">
12054
- <div class="desktopVideoHolder">
12055
- <video id="ScanDocAIVideoElement" class="desktopVideo" autoplay muted playsinline></video>
12056
- <div class="backgroundOverlay"></div>
12057
- <div class="centerGuideDot"></div>
12058
- </div>
12059
- </div>
12060
-
12061
- <style>
12062
- .desktopVideoArea {
12063
- display: flex;
12064
- flex-direction: column;
12065
- gap: 8px;
12066
- padding: 16px;
12067
- background-color: rgba(255, 255, 255, 0.05);
12068
- border: 1px solid ${borderColor};
12069
- border-radius: 15px;
12070
- margin-left: 20%;
12071
- margin-right: 20%;
12072
- }
12073
- .desktopVideoHolder {
12074
- position: relative;
12075
- width: 100%;
12076
- overflow: hidden;
12077
- }
12078
- .desktopVideo {
12079
- width: 100%;
12080
- height: auto;
12081
- margin-left: auto;
12082
- margin-right: auto;
12083
- max-width: 100vw;
12084
- max-height: 100vh;
12085
- }
12086
- .centerGuideDot {
12087
- position: absolute;
12088
- width: 16px;
12089
- height: 16px;
12090
- background-color: #00ff55;
12091
- border-radius: 50%;
12092
- box-shadow: 0 0 8px 2px rgba(0, 255, 100, 0.8);
12093
- z-index: 4;
12094
- animation: flicker 1s infinite;
12095
- pointer-events: none;
12096
- display: none;
12097
- }
12098
- @keyframes flicker {
12099
- 0%, 100% { opacity: 1; }
12100
- 50% { opacity: 0.3; }
12101
- }
12102
- .backgroundOverlay {
12103
- position: absolute;
12104
- top: 0;
12105
- left: 0;
12106
- width: 100%;
12107
- height: 100%;
12108
- background-color: rgba(0, 0, 0, 0.051);
12109
- z-index: 2;
12110
- pointer-events: none;
12111
- }
12112
- .desktopFeedback {
12113
- position: relative;
12114
- display: flex;
12115
- font-size: 22px;
12116
- font-weight: 600;
12117
- min-height: 36px;
12118
- justify-content: center;
12119
- align-items: center;
12120
- color: ${messageColor};
12121
- }
12122
- </style>
12123
- `;
12124
11587
  }
11588
+ return `
11589
+ <div class="desktopFeedback" id="ScanDocAIMessage"></div>
11590
+ <div class="desktopVideoArea">
11591
+ <div class="desktopVideoHolder">
11592
+ <video id="ScanDocAIVideoElement" class="desktopVideo" autoplay muted playsinline></video>
11593
+ <div class="backgroundOverlay"></div>
11594
+ <div class="centerGuideDot"></div>
11595
+ </div>
11596
+ </div>
11597
+
11598
+ <style>
11599
+ .desktopVideoArea {
11600
+ display: flex;
11601
+ flex-direction: column;
11602
+ gap: 8px;
11603
+ padding: 16px;
11604
+ background-color: rgba(255, 255, 255, 0.05);
11605
+ border: 1px solid ${borderColor};
11606
+ border-radius: 15px;
11607
+ margin-left: 20%;
11608
+ margin-right: 20%;
11609
+ }
11610
+
11611
+ .desktopVideoHolder {
11612
+ position: relative;
11613
+ width: 100%;
11614
+ overflow: hidden;
11615
+ }
11616
+
11617
+ .desktopVideo {
11618
+ width: 100%;
11619
+ height: auto;
11620
+ margin-left: auto;
11621
+ margin-right: auto;
11622
+ max-width: 100vw;
11623
+ max-height: 100vh;
11624
+ }
11625
+
11626
+ .centerGuideDot {
11627
+ position: absolute;
11628
+ width: 16px;
11629
+ height: 16px;
11630
+ background-color: #00ff55;
11631
+ border-radius: 50%;
11632
+ box-shadow: 0 0 8px 2px rgba(0, 255, 100, 0.8);
11633
+ z-index: 4;
11634
+ animation: flicker 1s infinite;
11635
+ pointer-events: none;
11636
+ display: none;
11637
+ }
11638
+
11639
+ @keyframes flicker {
11640
+ 0%, 100% { opacity: 1; }
11641
+ 50% { opacity: 0.3; }
11642
+ }
11643
+
11644
+ .backgroundOverlay {
11645
+ position: absolute;
11646
+ top: 0;
11647
+ left: 0;
11648
+ width: 100%;
11649
+ height: 100%;
11650
+ background-color: rgba(0, 0, 0, 0.051);
11651
+ z-index: 2;
11652
+ pointer-events: none;
11653
+ }
11654
+
11655
+ .desktopFeedback {
11656
+ position: relative;
11657
+ display: flex;
11658
+ font-size: 22px;
11659
+ font-weight: 600;
11660
+ min-height: 36px;
11661
+ justify-content: center;
11662
+ align-items: center;
11663
+ color: ${messageColor};
11664
+ }
11665
+ .scandoc-camera-debug {
11666
+ margin-top: 12px;
11667
+ padding: 10px;
11668
+ max-width: 95vw;
11669
+ overflow: auto;
11670
+ font-size: 12px;
11671
+ background: #f3f3f3;
11672
+ border: 1px solid #ddd;
11673
+ border-radius: 8px;
11674
+ white-space: pre-wrap;
11675
+ word-break: break-word;
11676
+ }
11677
+ </style>
11678
+ `;
12125
11679
  }
12126
11680
  }
12127
11681
  let EXTRACTION_VIDEO = undefined;
@@ -12151,6 +11705,339 @@ function getExtractionVideo(tokenOrCallback, maybeCallback) {
12151
11705
  }
12152
11706
  return EXTRACTION_VIDEO;
12153
11707
  }
11708
+ function delay(ms) {
11709
+ return new Promise(resolve => setTimeout(resolve, ms));
11710
+ }
11711
+ async function stopStream(stream) {
11712
+ if (!stream) return;
11713
+ for (const track of stream.getTracks()) {
11714
+ try {
11715
+ track.stop();
11716
+ } catch (_) {}
11717
+ }
11718
+ }
11719
+ function getAspectRatio(settings = {}) {
11720
+ if (typeof settings.aspectRatio === "number" && settings.aspectRatio > 0) {
11721
+ return settings.aspectRatio;
11722
+ }
11723
+ if (settings.width && settings.height) {
11724
+ return settings.width / settings.height;
11725
+ }
11726
+ return null;
11727
+ }
11728
+ async function attachStreamToVideo(videoEl, stream) {
11729
+ if (!videoEl) return;
11730
+ videoEl.srcObject = stream;
11731
+ try {
11732
+ await videoEl.play();
11733
+ } catch (_) {}
11734
+ }
11735
+ function getSupportedConstraintsSafe() {
11736
+ try {
11737
+ return navigator.mediaDevices?.getSupportedConstraints?.() || {};
11738
+ } catch (_) {
11739
+ return {};
11740
+ }
11741
+ }
11742
+ function buildDocumentScanConstraints(mode = "environment") {
11743
+ const supported = getSupportedConstraintsSafe();
11744
+ const video = {};
11745
+ if (supported.facingMode) {
11746
+ video.facingMode = typeof mode === "string" ? {
11747
+ ideal: mode
11748
+ } : mode || {
11749
+ ideal: "environment"
11750
+ };
11751
+ }
11752
+ if (supported.width) {
11753
+ video.width = {
11754
+ ideal: 1920
11755
+ };
11756
+ }
11757
+ if (supported.height) {
11758
+ video.height = {
11759
+ ideal: 1080
11760
+ };
11761
+ }
11762
+ if (supported.aspectRatio) {
11763
+ video.aspectRatio = {
11764
+ ideal: 16 / 9
11765
+ };
11766
+ }
11767
+ return {
11768
+ video,
11769
+ audio: false
11770
+ };
11771
+ }
11772
+ function buildDeviceConstraints(deviceId) {
11773
+ const supported = getSupportedConstraintsSafe();
11774
+ const video = {
11775
+ deviceId: {
11776
+ exact: deviceId
11777
+ }
11778
+ };
11779
+ if (supported.width) {
11780
+ video.width = {
11781
+ ideal: 1920
11782
+ };
11783
+ }
11784
+ if (supported.height) {
11785
+ video.height = {
11786
+ ideal: 1080
11787
+ };
11788
+ }
11789
+ if (supported.aspectRatio) {
11790
+ video.aspectRatio = {
11791
+ ideal: 16 / 9
11792
+ };
11793
+ }
11794
+ return {
11795
+ video,
11796
+ audio: false
11797
+ };
11798
+ }
11799
+ async function enumerateVideoInputDevices() {
11800
+ if (!navigator.mediaDevices?.enumerateDevices) return [];
11801
+ try {
11802
+ const devices = await navigator.mediaDevices.enumerateDevices();
11803
+ return devices.filter(d => d.kind === "videoinput");
11804
+ } catch (error) {
11805
+ console.warn("enumerateDevices failed:", error);
11806
+ return [];
11807
+ }
11808
+ }
11809
+ function normalizeLabel(label = "") {
11810
+ return String(label).trim().toLowerCase();
11811
+ }
11812
+ function isLikelyRearCamera(label = "") {
11813
+ const v = normalizeLabel(label);
11814
+ return v.includes("back") || v.includes("rear") || v.includes("environment") || v.includes("world");
11815
+ }
11816
+ function isLikelyFrontCamera(label = "") {
11817
+ const v = normalizeLabel(label);
11818
+ return v.includes("front") || v.includes("user") || v.includes("facetime") || v.includes("selfie");
11819
+ }
11820
+ function isLikelyUltraWideOrMacro(label = "") {
11821
+ const v = normalizeLabel(label);
11822
+ return v.includes("ultra") || v.includes("ultrawide") || v.includes("wide angle") || v.includes("0.5") || v.includes("macro") || v.includes("fisheye");
11823
+ }
11824
+ function isLikelyTelephoto(label = "") {
11825
+ const v = normalizeLabel(label);
11826
+ return v.includes("tele") || v.includes("zoom") || v.includes("3x") || v.includes("5x") || v.includes("10x");
11827
+ }
11828
+ function getPixelCount(settings = {}) {
11829
+ return Number(settings.width || 0) * Number(settings.height || 0);
11830
+ }
11831
+ function scoreCameraCandidate({
11832
+ label,
11833
+ settings,
11834
+ isBootstrap = false
11835
+ }) {
11836
+ const width = Number(settings?.width || 0);
11837
+ const height = Number(settings?.height || 0);
11838
+ const pixels = getPixelCount(settings);
11839
+ const aspectRatio = getAspectRatio(settings) || 0;
11840
+ let score = 0;
11841
+ score += pixels;
11842
+ if (isBootstrap) {
11843
+ score += 400000;
11844
+ }
11845
+ if (isLikelyRearCamera(label)) {
11846
+ score += 4000000;
11847
+ }
11848
+ if (isLikelyFrontCamera(label)) {
11849
+ score -= 12000000;
11850
+ }
11851
+ if (isLikelyUltraWideOrMacro(label)) {
11852
+ score -= 10000000;
11853
+ }
11854
+ if (isLikelyTelephoto(label)) {
11855
+ score -= 2500000;
11856
+ }
11857
+ if (aspectRatio >= 1.6 && aspectRatio <= 1.9) {
11858
+ score += 700000;
11859
+ }
11860
+ if (aspectRatio > 2.05) {
11861
+ score -= 1500000;
11862
+ }
11863
+ if (width >= 1920) {
11864
+ score += 500000;
11865
+ }
11866
+ if (width < 1280 || height < 720) {
11867
+ score -= 4000000;
11868
+ }
11869
+ return score;
11870
+ }
11871
+ async function probeCameraCandidate(device) {
11872
+ let stream = null;
11873
+ try {
11874
+ stream = await navigator.mediaDevices.getUserMedia(buildDeviceConstraints(device.deviceId));
11875
+ const track = stream.getVideoTracks?.()[0];
11876
+ const settings = track?.getSettings?.() || {};
11877
+ const label = track?.label || device.label || "";
11878
+ const score = scoreCameraCandidate({
11879
+ label,
11880
+ settings
11881
+ });
11882
+ return {
11883
+ ok: true,
11884
+ device,
11885
+ stream,
11886
+ label,
11887
+ deviceId: settings.deviceId || device.deviceId,
11888
+ settings,
11889
+ aspectRatio: getAspectRatio(settings),
11890
+ score
11891
+ };
11892
+ } catch (error) {
11893
+ if (stream) {
11894
+ await stopStream(stream);
11895
+ }
11896
+ return {
11897
+ ok: false,
11898
+ device,
11899
+ error,
11900
+ score: Number.NEGATIVE_INFINITY
11901
+ };
11902
+ }
11903
+ }
11904
+ function isIOSLike() {
11905
+ const ua = navigator.userAgent || "";
11906
+ return /iPhone|iPad|iPod/i.test(ua);
11907
+ }
11908
+ async function tryEnableDocumentFocus(stream) {
11909
+ const track = stream?.getVideoTracks?.()[0];
11910
+ if (!track) {
11911
+ return {
11912
+ ok: false,
11913
+ reason: "No video track found"
11914
+ };
11915
+ }
11916
+ if (!track.getCapabilities || !track.applyConstraints) {
11917
+ return {
11918
+ ok: false,
11919
+ reason: "Focus APIs unavailable on this browser/device",
11920
+ settingsBefore: track.getSettings ? track.getSettings() : null
11921
+ };
11922
+ }
11923
+ const caps = track.getCapabilities();
11924
+ const result = {
11925
+ ok: true,
11926
+ capabilities: caps,
11927
+ attempts: [],
11928
+ settingsBefore: track.getSettings ? track.getSettings() : null
11929
+ };
11930
+ try {
11931
+ let applied = false;
11932
+ if (Array.isArray(caps.focusMode) && caps.focusMode.includes("continuous")) {
11933
+ await track.applyConstraints({
11934
+ advanced: [{
11935
+ focusMode: "continuous"
11936
+ }]
11937
+ });
11938
+ result.attempts.push("Applied focusMode=continuous");
11939
+ applied = true;
11940
+ }
11941
+ if (caps.focusDistance) {
11942
+ const min = caps.focusDistance.min ?? 0;
11943
+ const max = caps.focusDistance.max ?? 0;
11944
+ const docDistance = min + (max - min) * 0.3;
11945
+ const advanced = [];
11946
+ if (Array.isArray(caps.focusMode) && caps.focusMode.includes("manual")) {
11947
+ advanced.push({
11948
+ focusMode: "manual"
11949
+ });
11950
+ }
11951
+ advanced.push({
11952
+ focusDistance: docDistance
11953
+ });
11954
+ await track.applyConstraints({
11955
+ advanced
11956
+ });
11957
+ result.attempts.push(`Applied focusDistance=${docDistance}`);
11958
+ applied = true;
11959
+ }
11960
+ if (!applied) {
11961
+ result.ok = false;
11962
+ result.reason = "No supported focus controls were exposed";
11963
+ }
11964
+ result.settingsAfter = track.getSettings ? track.getSettings() : null;
11965
+ return result;
11966
+ } catch (err) {
11967
+ result.ok = false;
11968
+ result.error = err.message || String(err);
11969
+ result.settingsAfter = track.getSettings ? track.getSettings() : null;
11970
+ return result;
11971
+ }
11972
+ }
11973
+ async function openBestDocumentCamera({
11974
+ videoElement,
11975
+ preferredFacingMode = "environment"
11976
+ }) {
11977
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
11978
+ throw new Error("getUserMedia is not available");
11979
+ }
11980
+ if (!window.isSecureContext) {
11981
+ throw new Error("Camera requires HTTPS / secure context");
11982
+ }
11983
+ let bootstrapStream;
11984
+ try {
11985
+ bootstrapStream = await navigator.mediaDevices.getUserMedia(buildDocumentScanConstraints(preferredFacingMode));
11986
+ } catch (preferredError) {
11987
+ console.warn("Preferred camera open failed, falling back:", preferredError);
11988
+ bootstrapStream = await navigator.mediaDevices.getUserMedia({
11989
+ video: true,
11990
+ audio: false
11991
+ });
11992
+ }
11993
+ const bootstrapTrack = bootstrapStream.getVideoTracks?.()[0];
11994
+ const bootstrapSettings = bootstrapTrack?.getSettings?.() || {};
11995
+ const bootstrapLabel = bootstrapTrack?.label || "";
11996
+ const bootstrapDeviceId = bootstrapSettings.deviceId || null;
11997
+ let best = {
11998
+ stream: bootstrapStream,
11999
+ label: bootstrapLabel,
12000
+ deviceId: bootstrapDeviceId,
12001
+ settings: bootstrapSettings,
12002
+ aspectRatio: getAspectRatio(bootstrapSettings),
12003
+ score: scoreCameraCandidate({
12004
+ label: bootstrapLabel,
12005
+ settings: bootstrapSettings,
12006
+ isBootstrap: true
12007
+ }),
12008
+ strategy: "bootstrap-environment",
12009
+ tested: []
12010
+ };
12011
+ const devices = await enumerateVideoInputDevices();
12012
+ const rearDevices = devices.filter(d => isLikelyRearCamera(d.label));
12013
+ const candidatesBase = rearDevices.length > 0 ? rearDevices : devices;
12014
+ const candidates = candidatesBase.filter(d => d.deviceId && d.deviceId !== bootstrapDeviceId).filter(d => !isLikelyFrontCamera(d.label)).slice(0, isIOSLike() ? 2 : 4);
12015
+ for (const device of candidates) {
12016
+ const probed = await probeCameraCandidate(device);
12017
+ best.tested.push({
12018
+ label: device.label || "",
12019
+ deviceId: device.deviceId,
12020
+ ok: probed.ok,
12021
+ settings: probed.settings || null,
12022
+ score: probed.score,
12023
+ error: probed.ok ? null : String(probed.error?.message || probed.error || "")
12024
+ });
12025
+ if (!probed.ok) continue;
12026
+ const clearlyBetter = probed.score > best.score + 1000000;
12027
+ if (clearlyBetter) {
12028
+ await stopStream(best.stream);
12029
+ best = {
12030
+ ...probed,
12031
+ strategy: "switched-after-ranking",
12032
+ tested: best.tested
12033
+ };
12034
+ } else {
12035
+ await stopStream(probed.stream);
12036
+ }
12037
+ }
12038
+ await attachStreamToVideo(videoElement, best.stream);
12039
+ return best;
12040
+ }
12154
12041
 
12155
12042
  /***/ }),
12156
12043
 
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.18",
4
+ "version": "0.1.20",
5
5
  "private": false,
6
6
  "description": "Pure JavaScript package for integrating ScanDoc-AI services.",
7
7
  "keywords": [