scandoc-ai-components 0.1.18 → 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 +471 -619
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11170,21 +11170,10 @@ __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 = {};
@@ -11198,47 +11187,8 @@ class ExtractorVideo {
11198
11187
  this.waitingForSecondSide = false;
11199
11188
  this.hasShownTurnMessage = false;
11200
11189
  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
- };
11190
+ this.currentStream = null;
11191
+ this.lastCameraInfo = null;
11242
11192
  }
11243
11193
  reset() {
11244
11194
  this.stopVideo();
@@ -11254,18 +11204,15 @@ class ExtractorVideo {
11254
11204
  this.lastMessage = null;
11255
11205
  this.waitingForSecondSide = false;
11256
11206
  this.hasShownTurnMessage = false;
11207
+ this.lastCameraInfo = null;
11257
11208
  clearTimeout(this.turnMessageTimer);
11258
11209
  this.turnMessageTimer = null;
11259
- this.debugLog = [];
11260
11210
  }
11261
11211
  async analyzeVideoStream() {
11262
11212
  if (!this.isRunning) return;
11263
11213
  const now = Date.now();
11264
11214
  const cfgValues = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfigValues)();
11265
11215
  if (this.scanStartTime && now - this.scanStartTime > cfgValues.MAX_SCAN_DURATION_MS) {
11266
- this.logDebug("scan_timeout", {
11267
- elapsedMs: now - this.scanStartTime
11268
- });
11269
11216
  this.stopVideo();
11270
11217
  this.onExtractedResults({
11271
11218
  success: false,
@@ -11276,16 +11223,7 @@ class ExtractorVideo {
11276
11223
  }
11277
11224
  const canvas = document.createElement("canvas");
11278
11225
  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
- });
11226
+ if (video && video.videoWidth < video.videoHeight) {
11289
11227
  this.showMessage("Please rotate your device to landscape mode.");
11290
11228
  this.candidateImages = [];
11291
11229
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
@@ -11304,9 +11242,6 @@ class ExtractorVideo {
11304
11242
  const [isValidationOk, response] = await (0,_requests_validation__WEBPACK_IMPORTED_MODULE_3__["default"])(images.map(e => e.validationImg), this.pastBlurValues, {});
11305
11243
  if (!isValidationOk) {
11306
11244
  const msg = Array.isArray(response) ? response.join(", ") : String(response || "");
11307
- this.logDebug("validation_failed", {
11308
- response: msg
11309
- });
11310
11245
  if (msg.toLowerCase().includes("token expired/invalid")) {
11311
11246
  this.stopVideo();
11312
11247
  this.showMessage("Token expired/invalid. Please provide a new token.", true);
@@ -11321,7 +11256,6 @@ class ExtractorVideo {
11321
11256
  return;
11322
11257
  }
11323
11258
  if (response?.InfoCode === "1006" && response?.TransactionID) {
11324
- this.logDebug("unsupported_document", response);
11325
11259
  const frontImage = this.candidateImages[this.candidateImages.length - 1].fullImg;
11326
11260
  await (0,_requests_error_logging__WEBPACK_IMPORTED_MODULE_4__["default"])(frontImage, "", response.TransactionID, response.OS, response.Device, response.Browser);
11327
11261
  this.showMessage("Document not supported.", true);
@@ -11403,10 +11337,6 @@ class ExtractorVideo {
11403
11337
  return;
11404
11338
  }
11405
11339
  this.candidateImages = [];
11406
- }).catch(err => {
11407
- this.logDebug("document_detector_error", {
11408
- message: err?.message || String(err)
11409
- });
11410
11340
  }).finally(() => {
11411
11341
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11412
11342
  });
@@ -11427,7 +11357,7 @@ class ExtractorVideo {
11427
11357
  this.turnMessageTimer = null;
11428
11358
  this.stopVideo();
11429
11359
  if (isExtractionOk) {
11430
- this.showMessage("Success - data extracted", "success");
11360
+ this.showMessage("Success - data extracted", true);
11431
11361
  const shouldStop = this.onExtractedResults({
11432
11362
  success: true,
11433
11363
  code: "001",
@@ -11435,7 +11365,7 @@ class ExtractorVideo {
11435
11365
  data: extractionData
11436
11366
  });
11437
11367
  if (shouldStop === true) {
11438
- setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11368
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11439
11369
  }
11440
11370
  } else {
11441
11371
  this.onExtractedResults({
@@ -11443,527 +11373,114 @@ class ExtractorVideo {
11443
11373
  code: "005",
11444
11374
  info: "Document validation passed but extraction failed."
11445
11375
  });
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
- };
11557
- }
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;
11376
+ setTimeout(() => this.startVideo(), ExtractorVideo.FREQUENCY_MS);
11740
11377
  }
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
11378
  }
11865
11379
  async startVideo() {
11866
11380
  try {
11867
- this.logDebug("startVideo_begin");
11868
11381
  const serviceConfig = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfig)();
11869
11382
  await serviceConfig.getAccessToken(false);
11870
11383
  const videoElem = document.getElementById("ScanDocAIVideoElement");
11871
11384
  if (!videoElem) {
11872
11385
  throw new Error("Video element not found.");
11873
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
+ }
11874
11393
  this.video = videoElem;
11875
11394
  this.isRunning = true;
11876
11395
  this.scanStartTime = Date.now();
11877
11396
  const cfgValues = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getScanDocAIConfigValues)();
11878
11397
  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
11398
+ await this.stopCurrentStream();
11399
+ const cameraResult = await openBestDocumentCamera({
11400
+ videoElement: this.video,
11401
+ preferredFacingMode: mode
11887
11402
  });
11403
+ this.currentStream = cameraResult.stream;
11404
+ this.lastCameraInfo = cameraResult;
11888
11405
  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;
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);
11901
11418
  }
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
11419
  setTimeout(() => this.adjustOverlayPosition(), 500);
11921
11420
  this.scanStartTime = Date.now();
11922
11421
  setTimeout(() => this.analyzeVideoStream(), ExtractorVideo.FREQUENCY_MS);
11923
11422
  this.showMessage("Starting scanning");
11924
- this.logDebug("startVideo_success", this.getDebugDump());
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
+ });
11925
11430
  return true;
11926
11431
  } catch (error) {
11927
11432
  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
11433
  this.isRunning = false;
11934
11434
  this.stopVideo();
11935
11435
  this.onExtractedResults({
11936
11436
  success: false,
11937
11437
  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
11438
+ info: error.status === 401 || error.status === 403 ? "Authentication failed: Invalid API key/token." : "Startup failed: " + (error.message || "Unknown error")
11940
11439
  });
11941
11440
  return false;
11942
11441
  }
11943
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
+ }
11944
11451
  stopVideo() {
11945
11452
  this.isRunning = false;
11946
- this.logDebug("stopVideo_called");
11947
11453
  if (this.video) {
11948
- this.video.pause();
11949
- if (this.video.srcObject !== undefined && this.video.srcObject !== null) {
11454
+ try {
11455
+ this.video.pause();
11456
+ } catch (_) {}
11457
+ if (this.video.srcObject) {
11950
11458
  try {
11951
11459
  this.video.srcObject.getTracks().forEach(t => t.stop());
11952
11460
  } catch (_) {}
11953
11461
  }
11954
11462
  this.video.srcObject = null;
11955
11463
  }
11464
+ if (this.currentStream) {
11465
+ try {
11466
+ this.currentStream.getTracks().forEach(t => t.stop());
11467
+ } catch (_) {}
11468
+ }
11469
+ this.currentStream = null;
11956
11470
  clearTimeout(this.turnMessageTimer);
11957
11471
  this.turnMessageTimer = null;
11958
11472
  }
11959
11473
  adjustOverlayPosition() {
11960
11474
  const video = this.video;
11961
11475
  const dot = document.querySelector(".centerGuideDot");
11962
- if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) return;
11476
+ if (!video || !dot || video.videoWidth === 0 || video.videoHeight === 0) {
11477
+ return;
11478
+ }
11963
11479
  const videoRatio = video.videoWidth / video.videoHeight;
11964
11480
  const container = video.parentElement;
11965
11481
  const containerRatio = container.clientWidth / container.clientHeight;
11966
- let scaleWidth, scaleHeight;
11482
+ let scaleWidth;
11483
+ let scaleHeight;
11967
11484
  if (videoRatio > containerRatio) {
11968
11485
  scaleWidth = container.clientWidth;
11969
11486
  scaleHeight = container.clientWidth / videoRatio;
@@ -12047,81 +11564,86 @@ class ExtractorVideo {
12047
11564
  }
12048
11565
  </style>
12049
11566
  `;
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
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
+ `;
12125
11647
  }
12126
11648
  }
12127
11649
  let EXTRACTION_VIDEO = undefined;
@@ -12151,6 +11673,336 @@ function getExtractionVideo(tokenOrCallback, maybeCallback) {
12151
11673
  }
12152
11674
  return EXTRACTION_VIDEO;
12153
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
+ }
12154
12006
 
12155
12007
  /***/ }),
12156
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.18",
4
+ "version": "0.1.19",
5
5
  "private": false,
6
6
  "description": "Pure JavaScript package for integrating ScanDoc-AI services.",
7
7
  "keywords": [