@jaak.ai/stamps 2.0.0-beta.3 → 2.0.0-beta.5

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 (72) hide show
  1. package/README.md +665 -514
  2. package/dist/cjs/jaak-stamps-webcomponent.cjs.js +1 -1
  3. package/dist/cjs/jaak-stamps.cjs.entry.js +1991 -1057
  4. package/dist/cjs/jaak-stamps.cjs.entry.js.map +1 -1
  5. package/dist/cjs/jaak-stamps.entry.cjs.js.map +1 -1
  6. package/dist/cjs/loader.cjs.js +1 -1
  7. package/dist/collection/components/my-component/my-component.css +483 -123
  8. package/dist/collection/components/my-component/my-component.js +915 -1120
  9. package/dist/collection/components/my-component/my-component.js.map +1 -1
  10. package/dist/collection/services/CameraService.js +590 -0
  11. package/dist/collection/services/CameraService.js.map +1 -0
  12. package/dist/collection/services/DetectionService.js +387 -0
  13. package/dist/collection/services/DetectionService.js.map +1 -0
  14. package/dist/collection/services/EventBusService.js +42 -0
  15. package/dist/collection/services/EventBusService.js.map +1 -0
  16. package/dist/collection/services/LoggerService.js +40 -0
  17. package/dist/collection/services/LoggerService.js.map +1 -0
  18. package/dist/collection/services/ServiceContainer.js +66 -0
  19. package/dist/collection/services/ServiceContainer.js.map +1 -0
  20. package/dist/collection/services/StateManagerService.js +109 -0
  21. package/dist/collection/services/StateManagerService.js.map +1 -0
  22. package/dist/collection/services/factories/DeviceStrategyFactory.js +16 -0
  23. package/dist/collection/services/factories/DeviceStrategyFactory.js.map +1 -0
  24. package/dist/collection/services/interfaces/ICameraService.js +2 -0
  25. package/dist/collection/services/interfaces/ICameraService.js.map +1 -0
  26. package/dist/collection/services/interfaces/IDetectionService.js +2 -0
  27. package/dist/collection/services/interfaces/IDetectionService.js.map +1 -0
  28. package/dist/collection/services/interfaces/IEventBus.js +2 -0
  29. package/dist/collection/services/interfaces/IEventBus.js.map +1 -0
  30. package/dist/collection/services/interfaces/ILogger.js +2 -0
  31. package/dist/collection/services/interfaces/ILogger.js.map +1 -0
  32. package/dist/collection/services/interfaces/IStateManager.js +2 -0
  33. package/dist/collection/services/interfaces/IStateManager.js.map +1 -0
  34. package/dist/collection/services/strategies/HighPerformanceDeviceStrategy.js +30 -0
  35. package/dist/collection/services/strategies/HighPerformanceDeviceStrategy.js.map +1 -0
  36. package/dist/collection/services/strategies/IDeviceStrategy.js +2 -0
  37. package/dist/collection/services/strategies/IDeviceStrategy.js.map +1 -0
  38. package/dist/collection/services/strategies/LowMemoryDeviceStrategy.js +30 -0
  39. package/dist/collection/services/strategies/LowMemoryDeviceStrategy.js.map +1 -0
  40. package/dist/collection/types/component-types.js +2 -0
  41. package/dist/collection/types/component-types.js.map +1 -0
  42. package/dist/components/jaak-stamps.js +2011 -1082
  43. package/dist/components/jaak-stamps.js.map +1 -1
  44. package/dist/esm/jaak-stamps-webcomponent.js +1 -1
  45. package/dist/esm/jaak-stamps.entry.js +1991 -1057
  46. package/dist/esm/jaak-stamps.entry.js.map +1 -1
  47. package/dist/esm/loader.js +1 -1
  48. package/dist/jaak-stamps-webcomponent/jaak-stamps-webcomponent.esm.js +1 -1
  49. package/dist/jaak-stamps-webcomponent/jaak-stamps.entry.esm.js.map +1 -1
  50. package/dist/jaak-stamps-webcomponent/p-9ed19fa4.entry.js +2 -0
  51. package/dist/jaak-stamps-webcomponent/p-9ed19fa4.entry.js.map +1 -0
  52. package/dist/types/components/my-component/my-component.d.ts +89 -108
  53. package/dist/types/components.d.ts +39 -9
  54. package/dist/types/services/CameraService.d.ts +47 -0
  55. package/dist/types/services/DetectionService.d.ts +35 -0
  56. package/dist/types/services/EventBusService.d.ts +9 -0
  57. package/dist/types/services/LoggerService.d.ts +12 -0
  58. package/dist/types/services/ServiceContainer.d.ts +27 -0
  59. package/dist/types/services/StateManagerService.d.ts +15 -0
  60. package/dist/types/services/factories/DeviceStrategyFactory.d.ts +4 -0
  61. package/dist/types/services/interfaces/ICameraService.d.ts +34 -0
  62. package/dist/types/services/interfaces/IDetectionService.d.ts +31 -0
  63. package/dist/types/services/interfaces/IEventBus.d.ts +16 -0
  64. package/dist/types/services/interfaces/ILogger.d.ts +8 -0
  65. package/dist/types/services/interfaces/IStateManager.d.ts +35 -0
  66. package/dist/types/services/strategies/HighPerformanceDeviceStrategy.d.ts +6 -0
  67. package/dist/types/services/strategies/IDeviceStrategy.d.ts +21 -0
  68. package/dist/types/services/strategies/LowMemoryDeviceStrategy.d.ts +6 -0
  69. package/dist/types/types/component-types.d.ts +36 -0
  70. package/package.json +3 -3
  71. package/dist/jaak-stamps-webcomponent/p-14eb13d8.entry.js +0 -2
  72. package/dist/jaak-stamps-webcomponent/p-14eb13d8.entry.js.map +0 -1
@@ -2,120 +2,212 @@
2
2
 
3
3
  var index = require('./index-BfhtOB0D.js');
4
4
 
5
- const myComponentCss = ":host{display:block;width:100%;height:100%;font-family:system-ui, -apple-system, sans-serif;color:#1a1a1a}.detector-container{display:flex;flex-direction:column;align-items:center;width:100%;height:100%}h1{font-size:24px;font-weight:500;color:#333;margin:0 0 24px 0}.video-container{position:relative;width:100%;height:100%;background:#333;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden}video,canvas{position:absolute;width:100%;height:100%;border-radius:8px}video.mirror,canvas.mirror{transform:rotateY(180deg)}canvas{z-index:1}video{z-index:0}.status{margin-top:16px;font-size:12px;color:#666}.overlay-mask{position:absolute;top:0;left:0;width:100%;height:100%;z-index:10;display:block;pointer-events:none}.card-outline{position:absolute;top:var(--mask-center-y, 50%);left:var(--mask-center-x, 50%);transform:translate(-50%, -50%);width:var(--mask-width, 88%);height:var(--mask-height, 55%);border:none;border-radius:4px;background:transparent;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0.7);opacity:0.8}.card-outline.perfect-match{box-shadow:0 0 0 9999px rgba(0, 0, 0, 0.7)}.side{position:absolute;background:#999;transition:background-color 0.3s ease}.side.aligned{background:#28a745}.side-top{top:0;left:0;width:100%;height:3px}.side-right{top:0;right:0;width:3px;height:100%}.side-bottom{bottom:0;left:0;width:100%;height:3px}.side-left{top:0;left:0;width:3px;height:100%}.corner{position:absolute;width:20px;height:20px;border:2px solid #999;z-index:12}.corner-tl{top:-10px;left:-10px;border-right:none;border-bottom:none}.corner-tr{top:-10px;right:-10px;border-left:none;border-bottom:none}.corner-bl{bottom:-10px;left:-10px;border-right:none;border-top:none}.corner-br{bottom:-10px;right:-10px;border-left:none;border-top:none}.corner.perfect-match{border-color:#28a745}.guide-text{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;font-size:14px;font-weight:600;text-align:center;white-space:normal;background:rgba(128, 128, 128, 0.8);padding:12px 20px;border-radius:20px;max-width:300px;z-index:20}.capture-animation{position:absolute;top:0;left:0;width:100%;height:100%;background:#fff;opacity:0;z-index:30;pointer-events:none;animation:captureFlash 0.6s ease-out}@keyframes captureFlash{0%{opacity:0}15%{opacity:0.8}30%{opacity:0}45%{opacity:0.4}60%{opacity:0}100%{opacity:0}}.card-outline.capturing{animation:pulseGreen 0.6s ease-out}@keyframes pulseGreen{0%{border-color:#28a745;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0.7), 0 0 0 4px rgba(40, 167, 69, 0)}50%{border-color:#28a745;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0.7), 0 0 0 8px rgba(40, 167, 69, 0.6)}100%{border-color:#28a745;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0.7), 0 0 0 4px rgba(40, 167, 69, 0)}}.flip-animation{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);z-index:35;pointer-events:none;opacity:0;animation:showFlipInstruction 3s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center}.id-card-icon{width:80px;height:50px;background:linear-gradient(135deg, #6c757d 0%, #495057 100%);border-radius:8px;position:relative;animation:flipCard 2s ease-in-out infinite;box-shadow:0 4px 12px rgba(0, 0, 0, 0.3)}.id-card-icon::before{content:'';position:absolute;top:8px;left:8px;width:16px;height:12px;background:rgba(255, 255, 255, 0.9);border-radius:2px}.id-card-icon::after{content:'';position:absolute;top:25px;left:8px;width:64px;height:3px;background:rgba(255, 255, 255, 0.7);border-radius:1px;box-shadow:0 6px 0 rgba(255, 255, 255, 0.7), 0 12px 0 rgba(255, 255, 255, 0.7)}.flip-text{margin-top:12px;color:#fff;font-size:14px;font-weight:600;text-align:center;background:rgba(128, 128, 128, 0.8);padding:12px 20px;border-radius:20px;max-width:300px}@keyframes showFlipInstruction{0%{opacity:0;transform:translate(-50%, -50%) scale(0.8)}15%{opacity:1;transform:translate(-50%, -50%) scale(1)}85%{opacity:1;transform:translate(-50%, -50%) scale(1)}100%{opacity:0;transform:translate(-50%, -50%) scale(0.8)}}@keyframes flipCard{0%{transform:rotateY(0deg)}50%{transform:rotateY(180deg)}100%{transform:rotateY(360deg)}}.success-animation{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);z-index:35;pointer-events:none;opacity:0;animation:showSuccessMessage 3s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center}.check-icon{width:80px;height:80px;background:linear-gradient(135deg, #6c757d 0%, #495057 100%);border-radius:50%;position:relative;animation:bounceSuccess 0.6s ease-out;box-shadow:0 4px 16px rgba(108, 117, 125, 0.4);display:flex;align-items:center;justify-content:center}.check-icon::before{content:'';position:absolute;width:20px;height:35px;border:4px solid #fff;border-top:none;border-left:none;transform:rotate(45deg);animation:drawCheck 0.4s ease-out 0.2s both}.success-text{margin-top:16px;color:#fff;font-size:14px;font-weight:600;text-align:center;background:rgba(128, 128, 128, 0.8);padding:12px 20px;border-radius:20px;max-width:300px;animation:fadeInUp 0.5s ease-out 0.4s both}@keyframes showSuccessMessage{0%{opacity:0;transform:translate(-50%, -50%) scale(0.5)}15%{opacity:1;transform:translate(-50%, -50%) scale(1)}85%{opacity:1;transform:translate(-50%, -50%) scale(1)}100%{opacity:0;transform:translate(-50%, -50%) scale(0.9)}}@keyframes bounceSuccess{0%{transform:scale(0)}50%{transform:scale(1.1)}100%{transform:scale(1)}}@keyframes drawCheck{0%{width:0;height:0}50%{width:20px;height:0}100%{width:20px;height:35px}}@keyframes fadeInUp{0%{opacity:0;transform:translateY(20px)}100%{opacity:1;transform:translateY(0)}}.skip-button{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:25;pointer-events:auto;background:#fff;color:#333;border:none;border-radius:25px;padding:12px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.3s ease}.skip-button:hover{background:#f8f9fa}.skip-button:active{background:#e9ecef;transform:translateX(-50%) translateY(0)}.camera-controls{position:absolute;top:16px;right:16px;z-index:25;display:flex;gap:8px;pointer-events:auto}.flip-camera-button{height:40px;padding:0 16px;border:none;border-radius:8px;background:rgba(0, 0, 0, 0.6);backdrop-filter:blur(12px);color:#ffffff;font-size:12px;font-weight:500;cursor:pointer;transition:all 0.2s ease;display:flex;align-items:center;justify-content:center;border:1px solid rgba(255, 255, 255, 0.1);white-space:nowrap;min-width:fit-content}.camera-selector-button{height:40px;padding:0 16px;border:none;border-radius:8px;background:rgba(0, 0, 0, 0.6);backdrop-filter:blur(12px);color:#ffffff;font-size:12px;font-weight:500;cursor:pointer;transition:all 0.2s ease;display:flex;align-items:center;justify-content:center;border:1px solid rgba(255, 255, 255, 0.1);white-space:nowrap;min-width:fit-content}.flip-camera-button:hover,.camera-selector-button:hover{background:rgba(0, 0, 0, 0.8);border-color:rgba(255, 255, 255, 0.2);transform:translateY(-1px)}.flip-camera-button:active,.camera-selector-button:active{transform:translateY(0);background:rgba(0, 0, 0, 0.9)}.camera-selector-dropdown{position:absolute;top:56px;right:16px;z-index:30;background:rgba(0, 0, 0, 0.85);backdrop-filter:blur(20px);border-radius:12px;min-width:260px;max-width:300px;border:1px solid rgba(255, 255, 255, 0.1);overflow:hidden;pointer-events:auto;animation:slideInFromTop 0.3s ease-out}@keyframes slideInFromTop{0%{opacity:0;transform:translateY(-8px) scale(0.95)}100%{opacity:1;transform:translateY(0) scale(1)}}.camera-selector-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255, 255, 255, 0.1)}.camera-selector-header span{font-weight:500;color:#ffffff;font-size:13px;opacity:0.9}.close-selector{width:20px;height:20px;border:none;background:none;color:rgba(255, 255, 255, 0.7);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all 0.2s ease}.close-selector:hover{background:rgba(255, 255, 255, 0.1);color:#ffffff}.camera-list{padding:4px 0;max-height:240px;overflow-y:auto}.camera-option{width:100%;padding:10px 16px;border:none;background:none;text-align:left;cursor:pointer;transition:all 0.2s ease;display:flex;justify-content:space-between;align-items:center;color:rgba(255, 255, 255, 0.9)}.camera-option:hover{background:rgba(255, 255, 255, 0.08)}.camera-option.selected{background:rgba(255, 255, 255, 0.12);color:#ffffff}.camera-label{font-size:13px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:8px;font-weight:400}.selected-indicator{font-size:14px;color:#ffffff;opacity:0.9}.device-info{padding:8px 16px;border-top:1px solid rgba(255, 255, 255, 0.1);background:rgba(255, 255, 255, 0.05)}.device-info small{color:rgba(255, 255, 255, 0.6);font-size:11px;text-transform:capitalize;font-weight:400}@media (max-width: 480px){.camera-controls{top:12px;right:12px;gap:6px}.flip-camera-button{height:36px;padding:0 12px;font-size:11px;border-radius:6px}.camera-selector-button{height:36px;padding:0 12px;font-size:11px;border-radius:6px}.camera-selector-dropdown{right:12px;top:48px;min-width:240px;max-width:calc(100vw - 24px)}.camera-selector-header{padding:10px 14px}.camera-option{padding:8px 14px}.device-info{padding:6px 14px}}.watermark{position:absolute;bottom:12px;right:12px;z-index:15;pointer-events:none;opacity:0.7}.watermark img{height:24px;width:auto;filter:drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))}.loading-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.8);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30;border-radius:20px}.loading-spinner{width:60px;height:60px;border:4px solid rgba(255, 255, 255, 0.2);border-top:4px solid #007bff;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}.loading-text{color:white;font-size:16px;font-weight:500;text-align:center;opacity:0.9}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}";
5
+ class LoggerService {
6
+ debugMode;
7
+ constructor(debug = false) {
8
+ this.debugMode = debug;
9
+ }
10
+ setDebugMode(debug) {
11
+ this.debugMode = debug;
12
+ }
13
+ info(...args) {
14
+ if (this.debugMode) {
15
+ console.log(`[JAAK-STAMPS] [INFO] [${new Date().toLocaleTimeString()}]`, ...args);
16
+ }
17
+ }
18
+ warn(...args) {
19
+ if (this.debugMode) {
20
+ console.warn(`[JAAK-STAMPS] [WARN] [${new Date().toLocaleTimeString()}]`, ...args);
21
+ }
22
+ }
23
+ error(...args) {
24
+ if (this.debugMode) {
25
+ console.error(`[JAAK-STAMPS] [ERROR] [${new Date().toLocaleTimeString()}]`, ...args);
26
+ }
27
+ }
28
+ debug(...args) {
29
+ if (this.debugMode) {
30
+ console.debug(`[JAAK-STAMPS] [DEBUG] [${new Date().toLocaleTimeString()}]`, ...args);
31
+ }
32
+ }
33
+ state(state, data) {
34
+ if (this.debugMode) {
35
+ console.log(`[JAAK-STAMPS] [STATE] [${new Date().toLocaleTimeString()}] ${state}`, data || '');
36
+ }
37
+ }
38
+ performance(operation, duration) {
39
+ if (this.debugMode) {
40
+ console.log(`[JAAK-STAMPS] [PERF] [${new Date().toLocaleTimeString()}] ${operation}: ${duration}ms`);
41
+ }
42
+ }
43
+ }
6
44
 
7
- const JaakStamps = class {
8
- constructor(hostRef) {
9
- index.registerInstance(this, hostRef);
10
- this.captureCompleted = index.createEvent(this, "captureCompleted");
11
- this.isReady = index.createEvent(this, "isReady");
45
+ class EventBusService {
46
+ events = new Map();
47
+ on(event, callback) {
48
+ if (!this.events.has(event)) {
49
+ this.events.set(event, []);
50
+ }
51
+ this.events.get(event).push(callback);
12
52
  }
13
- get el() { return index.getElement(this); }
14
- debug = false;
15
- alignmentTolerance = 10; // Tolerancia en píxeles para considerar un lado alineado (escalado para 320x320)
16
- maskSize = 90; // Porcentaje del video que ocupará la máscara (default 90%)
17
- cropMargin = 0; // Margen en píxeles para el recorte del documento (default 0)
18
- useDocumentClassification = false; // Habilita la clasificación automática de documentos para determinar si solicitar reverso
19
- preferredCamera = 'auto'; // Define qué cámara usar: 'auto' (automático según dispositivo), 'front' (frontal), 'back' (trasera)
20
- captureCompleted;
21
- isReady;
22
- isVideoActive = false;
23
- statusMessage = 'Presione el botón para activar la cámara';
24
- statusColor = '#aaa';
25
- bestScore = 0;
26
- capturedFullFrame = null;
27
- capturedCroppedId = null;
28
- capturedBackFullFrame = null;
29
- capturedBackCroppedId = null;
30
- captureStep = 'front';
31
- isCapturing = false;
32
- showFlipAnimation = false;
33
- showSuccessAnimation = false;
34
- shouldMirrorVideo = true;
35
- sideAlignment = {
36
- top: false,
37
- right: false,
38
- bottom: false,
39
- left: false
40
- };
41
- isDetectionPaused = false;
42
- isLoading = false;
43
- isModelPreloaded = false;
44
- isMaskReady = false;
45
- availableCameras = [];
46
- selectedCameraId = null;
47
- showCameraSelector = false;
48
- isMultipleCamerasAvailable = false;
49
- videoRef;
50
- canvasRef;
51
- session;
52
- startTime;
53
- videoStream;
54
- animationId;
55
- hasScreenshotTaken = false;
56
- lastDetectedBox;
57
- mobileNetSession;
58
- mobileNetClassMap;
59
- // Camera management properties
60
- deviceType = 'desktop';
61
- preferredCameraFacing = null;
62
- // Performance optimization properties
63
- frameSkipCounter = 0;
64
- FRAME_SKIP = 2; // Process every 3rd frame (reduces CPU by ~66%)
65
- consecutiveFailures = 0;
66
- MAX_FAILURES = 30; // 0.5 seconds without detection
67
- lastInferenceTime = 0;
68
- MIN_INFERENCE_INTERVAL = 50; // Minimum 50ms between inferences
69
- // Canvas pooling for memory optimization
70
- preprocessCanvas;
71
- preprocessCtx;
72
- captureCanvas;
73
- captureCtx;
74
- MODEL_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/ddmyp-v1.onnx";
75
- MOBILENET_MODEL_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/cdmmp-v1.onnx";
76
- MOBILENET_CLASSES_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/cdmmp-v1.json";
77
- INPUT_SIZE = 320;
78
- CONFIDENCE_THRESHOLD = 0.6;
79
- // ISO/IEC 7810 ID-1 standard dimensions (85.60mm x 53.98mm)
80
- ID1_ASPECT_RATIO = 85.60 / 53.98; // 1.5863320574...
81
- debugLog(...args) {
82
- if (this.debug) {
83
- console.log(...args);
53
+ off(event, callback) {
54
+ const callbacks = this.events.get(event);
55
+ if (callbacks) {
56
+ const index = callbacks.indexOf(callback);
57
+ if (index > -1) {
58
+ callbacks.splice(index, 1);
59
+ }
84
60
  }
85
61
  }
86
- validateMaskSize() {
87
- if (this.maskSize < 50 || this.maskSize > 100) {
88
- console.warn(`maskSize debe estar entre 50 y 100. Valor actual: ${this.maskSize}. Usando valor por defecto: 90`);
89
- this.maskSize = 90;
62
+ emit(event, data) {
63
+ const callbacks = this.events.get(event);
64
+ if (callbacks) {
65
+ callbacks.forEach(callback => {
66
+ try {
67
+ callback(data);
68
+ }
69
+ catch (error) {
70
+ console.error(`Error in event callback for ${event}:`, error);
71
+ }
72
+ });
90
73
  }
91
74
  }
92
- validateCropMargin() {
93
- if (this.cropMargin < 0 || this.cropMargin > 100) {
94
- console.warn(`cropMargin debe estar entre 0 y 100. Valor actual: ${this.cropMargin}. Usando valor por defecto: 0`);
95
- this.cropMargin = 0;
75
+ once(event, callback) {
76
+ const onceCallback = (data) => {
77
+ callback(data);
78
+ this.off(event, onceCallback);
79
+ };
80
+ this.on(event, onceCallback);
81
+ }
82
+ clear() {
83
+ this.events.clear();
84
+ }
85
+ }
86
+
87
+ class StateManagerService {
88
+ eventBus;
89
+ captureState = {
90
+ step: 'front',
91
+ isCapturing: false,
92
+ isDetectionPaused: false,
93
+ isVideoActive: false,
94
+ isLoading: false,
95
+ showFlipAnimation: false,
96
+ showSuccessAnimation: false,
97
+ bestScore: 0,
98
+ hasScreenshotTaken: false
99
+ };
100
+ capturedImages = {
101
+ front: {
102
+ fullFrame: null,
103
+ cropped: null
104
+ },
105
+ back: {
106
+ fullFrame: null,
107
+ cropped: null
108
+ },
109
+ metadata: {
110
+ totalImages: 0,
111
+ processCompleted: false,
112
+ backCaptureSkipped: false
96
113
  }
114
+ };
115
+ constructor(eventBus) {
116
+ this.eventBus = eventBus;
97
117
  }
98
- validatePreferredCamera() {
99
- const validOptions = ['auto', 'front', 'back'];
100
- if (!validOptions.includes(this.preferredCamera)) {
101
- console.warn(`preferredCamera debe ser uno de: ${validOptions.join(', ')}. Valor actual: ${this.preferredCamera}. Usando valor por defecto: 'auto'`);
102
- this.preferredCamera = 'auto';
118
+ getCaptureState() {
119
+ return { ...this.captureState };
120
+ }
121
+ updateCaptureState(updates) {
122
+ const previousState = { ...this.captureState };
123
+ this.captureState = { ...this.captureState, ...updates };
124
+ // Emit state change event
125
+ this.eventBus.emit('state-changed', {
126
+ previous: previousState,
127
+ current: this.captureState,
128
+ changes: updates
129
+ });
130
+ }
131
+ getCapturedImages() {
132
+ return JSON.parse(JSON.stringify(this.capturedImages));
133
+ }
134
+ setCapturedImages(images) {
135
+ this.capturedImages = {
136
+ ...this.capturedImages,
137
+ ...images,
138
+ metadata: {
139
+ ...this.capturedImages.metadata,
140
+ ...images.metadata
141
+ }
142
+ };
143
+ // Update total images count
144
+ let totalImages = 0;
145
+ if (this.capturedImages.front.fullFrame && this.capturedImages.front.cropped) {
146
+ totalImages += 2;
103
147
  }
148
+ if (this.capturedImages.back.fullFrame && this.capturedImages.back.cropped) {
149
+ totalImages += 2;
150
+ }
151
+ this.capturedImages.metadata.totalImages = totalImages;
104
152
  }
105
- emitReadyEvent() {
106
- const isDocumentReady = !!window.ort && this.isModelPreloaded;
107
- this.isReady.emit(isDocumentReady);
108
- this.debugLog('🟢 isReady event emitted:', isDocumentReady);
153
+ reset() {
154
+ this.captureState = {
155
+ step: 'front',
156
+ isCapturing: false,
157
+ isDetectionPaused: false,
158
+ isVideoActive: false,
159
+ isLoading: false,
160
+ showFlipAnimation: false,
161
+ showSuccessAnimation: false,
162
+ bestScore: 0,
163
+ hasScreenshotTaken: false
164
+ };
165
+ this.capturedImages = {
166
+ front: {
167
+ fullFrame: null,
168
+ cropped: null
169
+ },
170
+ back: {
171
+ fullFrame: null,
172
+ cropped: null
173
+ },
174
+ metadata: {
175
+ totalImages: 0,
176
+ processCompleted: false,
177
+ backCaptureSkipped: false
178
+ }
179
+ };
180
+ this.eventBus.emit('state-changed', {
181
+ previous: null,
182
+ current: this.captureState,
183
+ changes: { reset: true }
184
+ });
109
185
  }
110
- isRearCamera(stream) {
111
- const videoTrack = stream.getVideoTracks()[0];
112
- if (!videoTrack)
113
- return false;
114
- const settings = videoTrack.getSettings();
115
- return settings.facingMode === 'environment';
186
+ isProcessCompleted() {
187
+ return this.captureState.step === 'completed';
188
+ }
189
+ canProceedToBack() {
190
+ return this.captureState.step === 'front' &&
191
+ this.capturedImages.front.fullFrame !== null &&
192
+ this.capturedImages.front.cropped !== null;
193
+ }
194
+ }
195
+
196
+ class CameraService {
197
+ logger;
198
+ eventBus;
199
+ availableCameras = [];
200
+ selectedCameraId = null;
201
+ deviceType = 'desktop';
202
+ preferredCameraFacing = null;
203
+ preferredCamera = 'auto';
204
+ isManuallySelected = false;
205
+ constructor(logger, eventBus, preferredCamera = 'auto') {
206
+ this.logger = logger;
207
+ this.eventBus = eventBus;
208
+ this.preferredCamera = preferredCamera;
116
209
  }
117
- async detectDeviceTypeAndCameras() {
118
- // Detect device type
210
+ async detectDeviceType() {
119
211
  const userAgent = navigator.userAgent;
120
212
  const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
121
213
  const isTablet = /iPad|Android/i.test(userAgent) && window.innerWidth >= 768;
@@ -128,288 +220,1315 @@ const JaakStamps = class {
128
220
  else {
129
221
  this.deviceType = 'desktop';
130
222
  }
131
- this.debugLog('📱 Device type detected:', this.deviceType);
132
- // Enumerate available cameras
133
- await this.enumerateAndDetectCameras();
134
- // Load user preference
135
- this.loadCameraPreference();
223
+ this.logger.state('DISPOSITIVO_DETECTADO', {
224
+ deviceType: this.deviceType,
225
+ userAgent: navigator.userAgent,
226
+ screenDimensions: { width: window.innerWidth, height: window.innerHeight }
227
+ });
228
+ return this.deviceType;
136
229
  }
137
- async enumerateAndDetectCameras() {
230
+ async enumerateDevices() {
138
231
  try {
139
- // First, check if we have permission to enumerate devices
140
232
  const permissionStatus = await this.checkCameraPermission();
141
233
  if (permissionStatus === 'denied') {
142
- this.debugLog(' Camera permission denied');
143
- this.statusMessage = "Permiso de cámara denegado";
144
- this.statusColor = "#ff6b6b";
145
- return;
234
+ this.logger.error('Permiso de cámara denegado por el usuario');
235
+ return [];
146
236
  }
147
- // Request minimal permission to get device labels
148
237
  if (permissionStatus === 'prompt') {
149
238
  const tempStream = await navigator.mediaDevices.getUserMedia({ video: true });
150
239
  tempStream.getTracks().forEach(track => track.stop());
151
240
  }
152
- // Now enumerate devices with labels
153
241
  const devices = await navigator.mediaDevices.enumerateDevices();
154
242
  this.availableCameras = devices.filter(device => device.kind === 'videoinput');
155
- this.isMultipleCamerasAvailable = this.availableCameras.length > 1;
156
- this.debugLog('📹 Available cameras:', {
243
+ this.logger.state('CAMARAS_DETECTADAS', {
157
244
  count: this.availableCameras.length,
158
- isMultipleCamerasAvailable: this.isMultipleCamerasAvailable,
159
245
  cameras: this.availableCameras.map(cam => ({
160
246
  id: cam.deviceId,
161
247
  label: cam.label || 'Unknown Camera'
162
248
  }))
163
249
  });
164
- // Set initial camera preference based on device type
165
250
  this.setInitialCameraPreference();
251
+ this.logger.state('PREFERENCIA_INICIAL_APLICADA', {
252
+ selectedCameraId: this.selectedCameraId,
253
+ preferredCameraFacing: this.preferredCameraFacing,
254
+ preferredCamera: this.preferredCamera
255
+ });
256
+ this.eventBus.emit('camera-changed', this.selectedCameraId);
257
+ return this.availableCameras;
166
258
  }
167
259
  catch (error) {
168
- this.debugLog('Error enumerating cameras:', error);
260
+ this.logger.error('Error al enumerar cámaras disponibles:', error);
169
261
  this.handleCameraPermissionError(error);
262
+ return [];
170
263
  }
171
264
  }
172
- async checkCameraPermission() {
265
+ getAvailableCameras() {
266
+ return [...this.availableCameras];
267
+ }
268
+ isMultipleCamerasAvailable() {
269
+ return this.availableCameras.length > 1;
270
+ }
271
+ getSelectedCameraId() {
272
+ return this.selectedCameraId;
273
+ }
274
+ async setSelectedCamera(cameraId) {
275
+ const camera = this.availableCameras.find(cam => cam.deviceId === cameraId);
276
+ if (!camera) {
277
+ throw new Error(`Camera with ID ${cameraId} not found`);
278
+ }
279
+ this.selectedCameraId = cameraId;
280
+ this.updatePreferredFacing(camera);
281
+ this.isManuallySelected = true; // Mark as manually selected
282
+ this.savePreference();
283
+ this.eventBus.emit('camera-changed', cameraId);
284
+ }
285
+ getPreferredFacing() {
286
+ return this.preferredCameraFacing;
287
+ }
288
+ async setupCamera(constraints) {
173
289
  try {
174
- if (!navigator.permissions) {
175
- return 'prompt'; // Assume we need to prompt on older browsers
176
- }
177
- const permission = await navigator.permissions.query({ name: 'camera' });
178
- return permission.state;
290
+ const finalConstraints = constraints || await this.getMaxResolution();
291
+ const stream = await navigator.mediaDevices.getUserMedia({
292
+ video: finalConstraints,
293
+ audio: false
294
+ });
295
+ return stream;
179
296
  }
180
297
  catch (error) {
181
- this.debugLog('⚠️ Could not check camera permission:', error);
182
- return 'prompt';
298
+ this.logger.error('Error en setupCamera, intentando con restricciones básicas:', error);
299
+ // Fallback to basic constraints if getMaxResolution fails
300
+ const basicConstraints = {
301
+ width: { ideal: 1280 },
302
+ height: { ideal: 720 }
303
+ };
304
+ // Only add facingMode if not on desktop to avoid OverconstrainedError
305
+ if (this.deviceType !== 'desktop' && this.preferredCameraFacing) {
306
+ basicConstraints.facingMode = this.preferredCameraFacing;
307
+ }
308
+ this.logger.state('USANDO_RESTRICCIONES_BASICAS', { constraints: basicConstraints });
309
+ return await navigator.mediaDevices.getUserMedia({
310
+ video: basicConstraints,
311
+ audio: false
312
+ });
183
313
  }
184
314
  }
185
- handleCameraPermissionError(error) {
186
- if (error.name === 'NotAllowedError') {
187
- this.statusMessage = "Permiso de cámara denegado. Active el permiso en configuración.";
188
- this.statusColor = "#ff6b6b";
189
- this.availableCameras = [];
190
- this.isMultipleCamerasAvailable = false;
191
- }
192
- else if (error.name === 'NotFoundError') {
193
- this.statusMessage = "No se encontraron cámaras disponibles.";
194
- this.statusColor = "#ff6b6b";
195
- this.availableCameras = [];
196
- this.isMultipleCamerasAvailable = false;
197
- }
198
- else if (error.name === 'NotReadableError') {
199
- this.statusMessage = "Cámara en uso por otra aplicación.";
200
- this.statusColor = "#ff6b6b";
201
- this.availableCameras = [];
202
- this.isMultipleCamerasAvailable = false;
203
- }
204
- else {
205
- this.statusMessage = "Error al acceder a las cámaras.";
206
- this.statusColor = "#ff6b6b";
207
- this.availableCameras = [];
208
- this.isMultipleCamerasAvailable = false;
315
+ async switchCamera(cameraId) {
316
+ const selectedCamera = this.availableCameras.find(cam => cam.deviceId === cameraId);
317
+ if (!selectedCamera) {
318
+ this.logger.warn('Cámara seleccionada no encontrada, re-enumerando dispositivos...');
319
+ await this.enumerateDevices();
320
+ return;
209
321
  }
322
+ await this.setSelectedCamera(cameraId);
323
+ this.logger.state('CAMARA_CAMBIADA', {
324
+ label: selectedCamera.label,
325
+ deviceId: selectedCamera.deviceId,
326
+ isManuallySelected: this.isManuallySelected
327
+ });
210
328
  }
211
- setInitialCameraPreference() {
212
- if (this.availableCameras.length === 0)
329
+ async flipToNextCamera() {
330
+ if (!this.isMultipleCamerasAvailable())
213
331
  return;
214
- // Apply user preference for camera selection
215
- if (this.preferredCamera === 'front') {
216
- // User explicitly wants front camera
217
- this.preferredCameraFacing = 'user';
218
- const frontCamera = this.availableCameras.find(camera => camera.label.toLowerCase().includes('front') ||
219
- camera.label.toLowerCase().includes('user') ||
220
- camera.label.toLowerCase().includes('selfie') ||
221
- !camera.label.toLowerCase().includes('back') && !camera.label.toLowerCase().includes('rear'));
222
- if (frontCamera) {
223
- this.selectedCameraId = frontCamera.deviceId;
224
- this.debugLog('👤 User selected front camera:', frontCamera.label);
225
- }
226
- else {
227
- this.selectedCameraId = this.availableCameras[0].deviceId;
228
- this.debugLog('⚠️ Front camera not found, using first available:', this.availableCameras[0].label);
229
- }
230
- }
231
- else if (this.preferredCamera === 'back') {
232
- // User explicitly wants back camera
233
- this.preferredCameraFacing = 'environment';
234
- const backCamera = this.availableCameras.find(camera => camera.label.toLowerCase().includes('back') ||
235
- camera.label.toLowerCase().includes('rear') ||
236
- camera.label.toLowerCase().includes('environment'));
237
- if (backCamera) {
238
- this.selectedCameraId = backCamera.deviceId;
239
- this.debugLog('📷 User selected back camera:', backCamera.label);
240
- }
241
- else {
242
- this.selectedCameraId = this.availableCameras[0].deviceId;
243
- this.debugLog('⚠️ Back camera not found, using first available:', this.availableCameras[0].label);
244
- }
332
+ const currentIndex = this.availableCameras.findIndex(camera => camera.deviceId === this.selectedCameraId);
333
+ const nextIndex = (currentIndex + 1) % this.availableCameras.length;
334
+ const nextCamera = this.availableCameras[nextIndex];
335
+ await this.switchCamera(nextCamera.deviceId);
336
+ }
337
+ savePreference() {
338
+ try {
339
+ const preference = {
340
+ cameraId: this.selectedCameraId,
341
+ facing: this.preferredCameraFacing,
342
+ timestamp: Date.now()
343
+ };
344
+ localStorage.setItem('jaak-stamps-camera-preference', JSON.stringify(preference));
345
+ this.logger.state('PREFERENCIA_CAMARA_GUARDADA', preference);
245
346
  }
246
- else {
247
- // Auto mode - use device type to determine best camera
248
- if (this.deviceType === 'mobile' || this.deviceType === 'tablet') {
249
- // For mobile/tablet, prefer rear camera for document scanning
250
- this.preferredCameraFacing = 'environment';
251
- const rearCamera = this.availableCameras.find(camera => camera.label.toLowerCase().includes('back') ||
252
- camera.label.toLowerCase().includes('rear') ||
253
- camera.label.toLowerCase().includes('environment'));
254
- if (rearCamera) {
255
- this.selectedCameraId = rearCamera.deviceId;
256
- this.debugLog('📱 Auto-selected rear camera for mobile:', rearCamera.label);
257
- }
258
- else {
259
- this.selectedCameraId = this.availableCameras[0].deviceId;
260
- this.debugLog('📱 Rear camera not found, using first available:', this.availableCameras[0].label);
261
- }
262
- }
263
- else {
264
- // For desktop, use first available camera (usually the only one)
265
- this.selectedCameraId = this.availableCameras[0].deviceId;
266
- this.debugLog('💻 Auto-selected desktop camera:', this.availableCameras[0].label);
267
- }
347
+ catch (error) {
348
+ this.logger.warn('Error al guardar preferencia de cámara:', error);
268
349
  }
269
350
  }
270
- loadCameraPreference() {
351
+ loadPreference() {
271
352
  try {
272
353
  const saved = localStorage.getItem('jaak-stamps-camera-preference');
273
354
  if (saved) {
274
355
  const preference = JSON.parse(saved);
275
- // Validate that the saved camera is still available
276
356
  const isStillAvailable = this.availableCameras.some(camera => camera.deviceId === preference.cameraId);
277
357
  if (isStillAvailable) {
278
358
  this.selectedCameraId = preference.cameraId;
279
359
  this.preferredCameraFacing = preference.facing;
280
- this.debugLog('💾 Loaded camera preference:', preference);
360
+ this.logger.state('PREFERENCIA_CAMARA_CARGADA', preference);
281
361
  }
282
362
  }
283
363
  }
284
364
  catch (error) {
285
- this.debugLog('⚠️ Error loading camera preference:', error);
365
+ this.logger.warn('Error al cargar preferencia de cámara:', error);
286
366
  }
287
367
  }
288
- saveCameraPreference() {
368
+ isRearCamera(stream) {
369
+ const videoTrack = stream.getVideoTracks()[0];
370
+ if (!videoTrack)
371
+ return false;
372
+ const settings = videoTrack.getSettings();
373
+ return settings.facingMode === 'environment';
374
+ }
375
+ getCameraInfo() {
376
+ return {
377
+ availableCameras: this.availableCameras.map(camera => ({
378
+ id: camera.deviceId,
379
+ label: camera.label || 'Unknown Camera',
380
+ selected: camera.deviceId === this.selectedCameraId
381
+ })),
382
+ selectedCameraId: this.selectedCameraId,
383
+ deviceType: this.deviceType,
384
+ isMultipleCamerasAvailable: this.isMultipleCamerasAvailable(),
385
+ preferredFacing: this.preferredCameraFacing
386
+ };
387
+ }
388
+ async checkCameraPermission() {
289
389
  try {
290
- const preference = {
291
- cameraId: this.selectedCameraId,
292
- facing: this.preferredCameraFacing,
293
- timestamp: Date.now()
294
- };
295
- localStorage.setItem('jaak-stamps-camera-preference', JSON.stringify(preference));
296
- this.debugLog('💾 Saved camera preference:', preference);
390
+ if (!navigator.permissions) {
391
+ return 'prompt';
392
+ }
393
+ const permission = await navigator.permissions.query({ name: 'camera' });
394
+ return permission.state;
297
395
  }
298
396
  catch (error) {
299
- this.debugLog('⚠️ Error saving camera preference:', error);
397
+ this.logger.warn('No se pudo verificar permisos de cámara:', error);
398
+ return 'prompt';
399
+ }
400
+ }
401
+ handleCameraPermissionError(error) {
402
+ this.availableCameras = [];
403
+ this.eventBus.emit('error', new Error(`Camera permission error: ${error.message}`));
404
+ }
405
+ setInitialCameraPreference() {
406
+ if (this.availableCameras.length === 0)
407
+ return;
408
+ // Clear any existing localStorage preferences to ensure fresh start
409
+ localStorage.removeItem('jaak-stamps-camera-preference');
410
+ this.logger.state('APLICANDO_PREFERENCIA_INICIAL', {
411
+ preferredCamera: this.preferredCamera,
412
+ availableCamerasCount: this.availableCameras.length
413
+ });
414
+ this.isManuallySelected = false; // Reset manual selection flag
415
+ if (this.preferredCamera === 'front') {
416
+ this.selectFrontCamera();
417
+ }
418
+ else if (this.preferredCamera === 'back') {
419
+ this.selectBackCamera();
420
+ }
421
+ else {
422
+ this.selectAutoCamera();
423
+ }
424
+ }
425
+ selectFrontCamera() {
426
+ this.preferredCameraFacing = 'user';
427
+ this.logger.state('BUSCANDO_CAMARA_FRONTAL', {
428
+ availableCameras: this.availableCameras.map(cam => ({
429
+ id: cam.deviceId,
430
+ label: cam.label
431
+ }))
432
+ });
433
+ const frontCamera = this.availableCameras.find(camera => {
434
+ const label = camera.label.toLowerCase();
435
+ return label.includes('front') ||
436
+ label.includes('user') ||
437
+ label.includes('selfie') ||
438
+ label.includes('frontal') ||
439
+ (!label.includes('back') && !label.includes('rear') && !label.includes('environment'));
440
+ });
441
+ // If not found, use the first camera (usually front on mobile)
442
+ this.selectedCameraId = frontCamera ? frontCamera.deviceId : this.availableCameras[0].deviceId;
443
+ this.logger.state('CAMARA_FRONTAL_SELECCIONADA', {
444
+ found: !!frontCamera,
445
+ label: frontCamera?.label || this.availableCameras[0].label,
446
+ deviceId: this.selectedCameraId
447
+ });
448
+ }
449
+ selectBackCamera() {
450
+ this.preferredCameraFacing = 'environment';
451
+ this.logger.state('BUSCANDO_CAMARA_TRASERA', {
452
+ availableCameras: this.availableCameras.map(cam => ({
453
+ id: cam.deviceId,
454
+ label: cam.label
455
+ }))
456
+ });
457
+ // Try to find rear camera with multiple detection methods
458
+ let backCamera = this.availableCameras.find(camera => {
459
+ const label = camera.label.toLowerCase();
460
+ return label.includes('back') ||
461
+ label.includes('rear') ||
462
+ label.includes('environment') ||
463
+ label.includes('trasera') ||
464
+ label.includes('posterior');
465
+ });
466
+ // If not found by label, try to use the last camera (usually rear on mobile)
467
+ if (!backCamera && this.availableCameras.length > 1) {
468
+ backCamera = this.availableCameras[this.availableCameras.length - 1];
469
+ this.logger.state('USANDO_ULTIMA_CAMARA_COMO_TRASERA', {
470
+ label: backCamera.label,
471
+ deviceId: backCamera.deviceId
472
+ });
473
+ }
474
+ this.selectedCameraId = backCamera ? backCamera.deviceId : this.availableCameras[0].deviceId;
475
+ this.logger.state('CAMARA_TRASERA_SELECCIONADA', {
476
+ found: !!backCamera,
477
+ label: backCamera?.label || this.availableCameras[0].label,
478
+ deviceId: this.selectedCameraId
479
+ });
480
+ }
481
+ selectAutoCamera() {
482
+ if (this.deviceType === 'mobile' || this.deviceType === 'tablet') {
483
+ this.selectBackCamera();
484
+ this.logger.state('CAMARA_AUTO_SELECCIONADA_MOBILE', { type: 'rear' });
485
+ }
486
+ else {
487
+ // For desktop, prefer front camera (usually built-in webcam)
488
+ this.selectFrontCamera();
489
+ this.logger.state('CAMARA_AUTO_SELECCIONADA_DESKTOP', {
490
+ type: 'front',
491
+ label: this.availableCameras[0].label,
492
+ deviceId: this.selectedCameraId
493
+ });
494
+ }
495
+ }
496
+ updatePreferredFacing(camera) {
497
+ const isRearCamera = camera.label.toLowerCase().includes('back') ||
498
+ camera.label.toLowerCase().includes('rear') ||
499
+ camera.label.toLowerCase().includes('environment');
500
+ this.preferredCameraFacing = isRearCamera ? 'environment' : 'user';
501
+ }
502
+ async getMaxResolution() {
503
+ try {
504
+ // Ensure device type is detected before determining constraints
505
+ if (!this.deviceType || this.deviceType === 'desktop') {
506
+ await this.detectDeviceType();
507
+ }
508
+ const videoConstraints = {};
509
+ this.logger.state('CONFIGURANDO_CONSTRAINS_CAMARA', {
510
+ selectedCameraId: this.selectedCameraId,
511
+ preferredCameraFacing: this.preferredCameraFacing,
512
+ preferredCamera: this.preferredCamera,
513
+ deviceType: this.deviceType,
514
+ isManuallySelected: this.isManuallySelected
515
+ });
516
+ if (this.isManuallySelected && this.selectedCameraId) {
517
+ // When manually selected, use deviceId for exact camera selection
518
+ videoConstraints.deviceId = { exact: this.selectedCameraId };
519
+ this.logger.state('USANDO_CAMARA_MANUAL', {
520
+ deviceId: this.selectedCameraId
521
+ });
522
+ }
523
+ else if (this.preferredCameraFacing) {
524
+ // Use facingMode for initial preference-based selection
525
+ // Use 'ideal' instead of 'exact' to avoid OverconstrainedError on desktop
526
+ const facingConstraint = this.deviceType === 'desktop'
527
+ ? { ideal: this.preferredCameraFacing }
528
+ : { exact: this.preferredCameraFacing };
529
+ videoConstraints.facingMode = facingConstraint;
530
+ this.logger.state('USANDO_FACING_CONSTRAINT', {
531
+ facingMode: this.preferredCameraFacing,
532
+ constraintType: this.deviceType === 'desktop' ? 'ideal' : 'exact'
533
+ });
534
+ }
535
+ else if (this.selectedCameraId) {
536
+ videoConstraints.deviceId = { exact: this.selectedCameraId };
537
+ this.logger.state('USANDO_CAMARA_SELECCIONADA', {
538
+ deviceId: this.selectedCameraId
539
+ });
540
+ }
541
+ else {
542
+ // Use preferredCamera setting to determine default facing mode
543
+ if (this.preferredCamera === 'front') {
544
+ videoConstraints.facingMode = "user";
545
+ }
546
+ else if (this.preferredCamera === 'back') {
547
+ videoConstraints.facingMode = "environment";
548
+ }
549
+ else {
550
+ // Auto mode: use environment for mobile/tablet, user for desktop
551
+ videoConstraints.facingMode = (this.deviceType === 'mobile' || this.deviceType === 'tablet') ? "environment" : "user";
552
+ }
553
+ this.logger.state('USANDO_PREFERENCIA_CONFIGURADA', {
554
+ facingMode: videoConstraints.facingMode,
555
+ preferredCamera: this.preferredCamera,
556
+ deviceType: this.deviceType
557
+ });
558
+ }
559
+ const tempStream = await navigator.mediaDevices.getUserMedia({
560
+ video: videoConstraints
561
+ });
562
+ const videoTrack = tempStream.getVideoTracks()[0];
563
+ const capabilities = videoTrack.getCapabilities();
564
+ tempStream.getTracks().forEach(track => track.stop());
565
+ const constraints = { ...videoConstraints };
566
+ // Enhanced focus and image quality settings
567
+ this.applyAdvancedCameraSettings(constraints, capabilities);
568
+ if (capabilities.width && capabilities.height) {
569
+ const maxWidth = Math.min(capabilities.width.max, 1920);
570
+ const maxHeight = Math.min(capabilities.height.max, 1080);
571
+ const isTablet = /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768;
572
+ if (isTablet) {
573
+ constraints.width = { ideal: Math.min(maxWidth, 1280) };
574
+ constraints.height = { ideal: Math.min(maxHeight, 720) };
575
+ }
576
+ else {
577
+ constraints.width = { ideal: maxWidth };
578
+ constraints.height = { ideal: maxHeight };
579
+ }
580
+ }
581
+ this.logger.state('CONFIGURACION_CAMARA_OPTIMIZADA', {
582
+ constraints,
583
+ capabilities: this.getCapabilitiesSummary(capabilities)
584
+ });
585
+ return constraints;
586
+ }
587
+ catch (err) {
588
+ this.logger.warn('No se pudieron obtener capacidades de cámara, usando configuración de respaldo');
589
+ // Ensure device type is detected before determining constraints
590
+ if (!this.deviceType || this.deviceType === 'desktop') {
591
+ await this.detectDeviceType();
592
+ }
593
+ const isTablet = /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768;
594
+ const fallbackConstraints = {
595
+ width: { ideal: isTablet ? 1280 : 1920 },
596
+ height: { ideal: isTablet ? 720 : 1080 }
597
+ };
598
+ // Apply basic focus settings even for fallback
599
+ this.applyBasicFocusSettings(fallbackConstraints);
600
+ if (this.isManuallySelected && this.selectedCameraId) {
601
+ // When manually selected, use deviceId for exact camera selection
602
+ fallbackConstraints.deviceId = { exact: this.selectedCameraId };
603
+ }
604
+ else if (this.preferredCameraFacing) {
605
+ // Use facingMode for initial preference-based selection
606
+ // Use 'ideal' instead of 'exact' to avoid OverconstrainedError on desktop
607
+ const facingConstraint = this.deviceType === 'desktop'
608
+ ? { ideal: this.preferredCameraFacing }
609
+ : { exact: this.preferredCameraFacing };
610
+ fallbackConstraints.facingMode = facingConstraint;
611
+ }
612
+ else if (this.selectedCameraId) {
613
+ fallbackConstraints.deviceId = { exact: this.selectedCameraId };
614
+ }
615
+ else {
616
+ // Use preferredCamera setting to determine default facing mode
617
+ if (this.preferredCamera === 'front') {
618
+ fallbackConstraints.facingMode = "user";
619
+ }
620
+ else if (this.preferredCamera === 'back') {
621
+ fallbackConstraints.facingMode = "environment";
622
+ }
623
+ else {
624
+ // Auto mode: use environment for mobile/tablet, user for desktop
625
+ fallbackConstraints.facingMode = (this.deviceType === 'mobile' || this.deviceType === 'tablet') ? "environment" : "user";
626
+ }
627
+ }
628
+ return fallbackConstraints;
629
+ }
630
+ }
631
+ applyAdvancedCameraSettings(constraints, capabilities) {
632
+ // Apply standard camera settings that are widely supported
633
+ // Frame rate optimization
634
+ if (capabilities.frameRate) {
635
+ constraints.frameRate = {
636
+ ideal: 30,
637
+ min: 15,
638
+ max: 60
639
+ };
640
+ }
641
+ // Width and height constraints for optimal resolution
642
+ if (capabilities.width && capabilities.height) {
643
+ constraints.width = {
644
+ ideal: Math.min(1920, capabilities.width.max || 1920),
645
+ min: 640
646
+ };
647
+ constraints.height = {
648
+ ideal: Math.min(1080, capabilities.height.max || 1080),
649
+ min: 480
650
+ };
651
+ }
652
+ // Apply enhanced constraints through advanced array for better browser compatibility
653
+ const advancedConstraints = [];
654
+ // Try to set optimal settings through advanced constraints
655
+ const advanced = {};
656
+ // Focus mode - use 'any' type to bypass TypeScript limitations
657
+ if (capabilities.focusMode) {
658
+ if (capabilities.focusMode.includes('continuous')) {
659
+ advanced.focusMode = 'continuous';
660
+ }
661
+ else if (capabilities.focusMode.includes('single-shot')) {
662
+ advanced.focusMode = 'single-shot';
663
+ }
664
+ }
665
+ // Focus distance for close objects
666
+ if (capabilities.focusDistance) {
667
+ advanced.focusDistance = {
668
+ ideal: 0.4, // 40cm - optimal for document capture
669
+ min: 0.2,
670
+ max: 1.0
671
+ };
672
+ }
673
+ // Zoom control
674
+ if (capabilities.zoom) {
675
+ advanced.zoom = {
676
+ ideal: 1.0,
677
+ min: capabilities.zoom.min,
678
+ max: Math.min(capabilities.zoom.max, 3.0)
679
+ };
680
+ }
681
+ // Add advanced constraints if any were set
682
+ if (Object.keys(advanced).length > 0) {
683
+ advancedConstraints.push(advanced);
684
+ }
685
+ if (advancedConstraints.length > 0) {
686
+ constraints.advanced = advancedConstraints;
687
+ }
688
+ }
689
+ applyBasicFocusSettings(constraints) {
690
+ // Apply basic focus settings when capabilities are not available
691
+ // Use advanced constraints for non-standard properties
692
+ const advanced = {
693
+ focusMode: 'continuous',
694
+ exposureMode: 'continuous',
695
+ whiteBalanceMode: 'continuous'
696
+ };
697
+ if (!constraints.advanced) {
698
+ constraints.advanced = [];
699
+ }
700
+ constraints.advanced.push(advanced);
701
+ constraints.frameRate = { ideal: 30 };
702
+ }
703
+ getCapabilitiesSummary(capabilities) {
704
+ return {
705
+ hasAutoFocus: !!capabilities.focusMode,
706
+ hasFocusDistance: !!capabilities.focusDistance,
707
+ hasExposureControl: !!capabilities.exposureMode,
708
+ hasWhiteBalance: !!capabilities.whiteBalanceMode,
709
+ hasZoom: !!capabilities.zoom,
710
+ hasTorch: capabilities.torch !== undefined,
711
+ hasImageControls: !!(capabilities.brightness || capabilities.contrast || capabilities.saturation || capabilities.sharpness),
712
+ resolutionRange: capabilities.width && capabilities.height ?
713
+ `${capabilities.width.min}x${capabilities.height.min} - ${capabilities.width.max}x${capabilities.height.max}` : 'unknown'
714
+ };
715
+ }
716
+ // New method to enable torch/flash for better illumination
717
+ async setTorchEnabled(enabled, stream) {
718
+ try {
719
+ if (!stream) {
720
+ this.logger.warn('No hay stream disponible para controlar el flash');
721
+ return false;
722
+ }
723
+ const videoTrack = stream.getVideoTracks()[0];
724
+ if (!videoTrack) {
725
+ this.logger.warn('No hay track de video disponible');
726
+ return false;
727
+ }
728
+ const capabilities = videoTrack.getCapabilities();
729
+ if (capabilities.torch === undefined) {
730
+ this.logger.warn('El dispositivo no soporta control de flash');
731
+ return false;
732
+ }
733
+ await videoTrack.applyConstraints({
734
+ advanced: [{ torch: enabled }]
735
+ });
736
+ this.logger.state('FLASH_CONTROLADO', { enabled });
737
+ return true;
738
+ }
739
+ catch (error) {
740
+ this.logger.error('Error al controlar el flash:', error);
741
+ return false;
742
+ }
743
+ }
744
+ // Method to manually trigger focus on a specific point
745
+ async focusAtPoint(x, y, stream) {
746
+ try {
747
+ if (!stream) {
748
+ this.logger.warn('No hay stream disponible para enfocar');
749
+ return false;
750
+ }
751
+ const videoTrack = stream.getVideoTracks()[0];
752
+ if (!videoTrack) {
753
+ this.logger.warn('No hay track de video disponible');
754
+ return false;
755
+ }
756
+ const capabilities = videoTrack.getCapabilities();
757
+ if (!capabilities.focusMode || !capabilities.focusMode.includes('single-shot')) {
758
+ this.logger.warn('El dispositivo no soporta enfoque manual');
759
+ return false;
760
+ }
761
+ // Trigger single-shot focus
762
+ await videoTrack.applyConstraints({
763
+ advanced: [{ focusMode: 'single-shot' }]
764
+ });
765
+ // Return to continuous focus after a short delay
766
+ setTimeout(async () => {
767
+ try {
768
+ await videoTrack.applyConstraints({
769
+ advanced: [{ focusMode: 'continuous' }]
770
+ });
771
+ }
772
+ catch (error) {
773
+ this.logger.warn('Error al volver a enfoque continuo:', error);
774
+ }
775
+ }, 1000);
776
+ this.logger.state('ENFOQUE_MANUAL_ACTIVADO', { x, y });
777
+ return true;
778
+ }
779
+ catch (error) {
780
+ this.logger.error('Error al enfocar manualmente:', error);
781
+ return false;
782
+ }
783
+ }
784
+ }
785
+
786
+ class LowMemoryDeviceStrategy {
787
+ getDeviceInfo() {
788
+ const nav = navigator;
789
+ const memory = nav.deviceMemory || nav.hardwareConcurrency || 4;
790
+ const connection = nav.connection || nav.mozConnection || nav.webkitConnection;
791
+ const isSlowConnection = connection && (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g');
792
+ return {
793
+ estimatedRAM: memory,
794
+ isLowMemory: true,
795
+ isSlowConnection: isSlowConnection
796
+ };
797
+ }
798
+ getSessionOptions(_debug) {
799
+ return {
800
+ executionProviders: ['wasm'],
801
+ graphOptimizationLevel: 'basic',
802
+ logSeverityLevel: 4,
803
+ logVerbosityLevel: 0,
804
+ enableCpuMemArena: false,
805
+ enableMemPattern: false,
806
+ executionMode: 'sequential',
807
+ interOpNumThreads: 1,
808
+ intraOpNumThreads: 1,
809
+ };
810
+ }
811
+ shouldUseSequentialLoading() {
812
+ return true;
813
+ }
814
+ }
815
+
816
+ class HighPerformanceDeviceStrategy {
817
+ getDeviceInfo() {
818
+ const nav = navigator;
819
+ const memory = nav.deviceMemory || nav.hardwareConcurrency || 4;
820
+ const connection = nav.connection || nav.mozConnection || nav.webkitConnection;
821
+ const isSlowConnection = connection && (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g');
822
+ return {
823
+ estimatedRAM: memory,
824
+ isLowMemory: false,
825
+ isSlowConnection: isSlowConnection
826
+ };
827
+ }
828
+ getSessionOptions(debug) {
829
+ return {
830
+ executionProviders: ['webgl', 'wasm'],
831
+ graphOptimizationLevel: 'all',
832
+ logSeverityLevel: debug ? 2 : 4,
833
+ logVerbosityLevel: 0,
834
+ enableCpuMemArena: true,
835
+ enableMemPattern: true,
836
+ executionMode: 'parallel',
837
+ interOpNumThreads: 2,
838
+ intraOpNumThreads: 2,
839
+ };
840
+ }
841
+ shouldUseSequentialLoading() {
842
+ return false;
843
+ }
844
+ }
845
+
846
+ class DeviceStrategyFactory {
847
+ static createStrategy() {
848
+ const nav = navigator;
849
+ const memory = nav.deviceMemory || nav.hardwareConcurrency || 4;
850
+ const isLowMemory = memory <= 4;
851
+ if (isLowMemory) {
852
+ return new LowMemoryDeviceStrategy();
853
+ }
854
+ else {
855
+ return new HighPerformanceDeviceStrategy();
856
+ }
857
+ }
858
+ }
859
+
860
+ class DetectionService {
861
+ logger;
862
+ debug;
863
+ useDocumentClassification;
864
+ session;
865
+ mobileNetSession;
866
+ mobileNetClassMap;
867
+ modelLoaded = false;
868
+ deviceStrategy;
869
+ MODEL_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/ddmyp-v2.onnx";
870
+ MOBILENET_MODEL_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/squeezenet_new_fp32.onnx";
871
+ MOBILENET_CLASSES_PATH = "https://storage.googleapis.com/jaak-static/web/component/stamps/cdmmp-v1.json";
872
+ INPUT_SIZE = 320;
873
+ CONFIDENCE_THRESHOLD = 0.6;
874
+ // Canvas pool for optimization
875
+ preprocessCanvas;
876
+ preprocessCtx;
877
+ captureCanvas;
878
+ constructor(logger, debug = false, useDocumentClassification = false) {
879
+ this.logger = logger;
880
+ this.debug = debug;
881
+ this.useDocumentClassification = useDocumentClassification;
882
+ this.deviceStrategy = DeviceStrategyFactory.createStrategy();
883
+ this.initializeCanvasPool();
884
+ }
885
+ async loadModel() {
886
+ if (this.modelLoaded || this.session) {
887
+ this.logger.state('MODELO_YA_PRECARGADO', {
888
+ sessionExists: !!this.session,
889
+ modelPreloaded: this.modelLoaded
890
+ });
891
+ return;
892
+ }
893
+ try {
894
+ this.logger.state('PRECARGANDO_MODELO_DETECCION', { modelPath: this.MODEL_PATH });
895
+ const sessionOptions = this.deviceStrategy.getSessionOptions(this.debug);
896
+ try {
897
+ this.session = await window.ort.InferenceSession.create(this.MODEL_PATH, sessionOptions);
898
+ }
899
+ catch (error) {
900
+ if (error.message.includes('failed to allocate a buffer')) {
901
+ this.logger.warn('Fallo en asignación de buffer durante precarga, intentando con configuración mínima');
902
+ const fallbackOptions = {
903
+ executionProviders: ['wasm'],
904
+ graphOptimizationLevel: 'disabled',
905
+ logSeverityLevel: 4,
906
+ enableCpuMemArena: false,
907
+ enableMemPattern: false,
908
+ executionMode: 'sequential',
909
+ interOpNumThreads: 1,
910
+ intraOpNumThreads: 1,
911
+ };
912
+ this.session = await window.ort.InferenceSession.create(this.MODEL_PATH, fallbackOptions);
913
+ }
914
+ else {
915
+ throw error;
916
+ }
917
+ }
918
+ this.modelLoaded = true;
919
+ this.logger.state('MODELO_DETECCION_CARGADO_EXITOSAMENTE', { sessionCreated: !!this.session });
920
+ }
921
+ catch (error) {
922
+ this.logger.error('Error al precargar modelo de detección:', error);
923
+ throw error;
924
+ }
925
+ }
926
+ async loadClassificationModel() {
927
+ if (!this.useDocumentClassification) {
928
+ return;
929
+ }
930
+ try {
931
+ this.logger.state('CARGANDO_MODELO_MOBILENET', { path: this.MOBILENET_MODEL_PATH });
932
+ // Try to load class map (optional - SqueezeNet may not need it for basic classification)
933
+ try {
934
+ const classResponse = await fetch(this.MOBILENET_CLASSES_PATH);
935
+ if (classResponse.ok) {
936
+ this.mobileNetClassMap = await classResponse.json();
937
+ this.logger.state('CLASES_MOBILENET_CARGADAS', {
938
+ classCount: Object.keys(this.mobileNetClassMap).length
939
+ });
940
+ }
941
+ }
942
+ catch (error) {
943
+ this.logger.warn('No se pudo cargar el mapa de clases de MobileNet, usando clasificación básica');
944
+ // Create a basic class map for document types
945
+ this.mobileNetClassMap = {
946
+ "0": "document",
947
+ "1": "id_card",
948
+ "2": "passport",
949
+ "3": "license",
950
+ "4": "other"
951
+ };
952
+ }
953
+ // Load model
954
+ const sessionOptions = this.deviceStrategy.getSessionOptions(this.debug);
955
+ try {
956
+ this.mobileNetSession = await window.ort.InferenceSession.create(this.MOBILENET_MODEL_PATH, sessionOptions);
957
+ }
958
+ catch (error) {
959
+ if (error.message.includes('failed to allocate a buffer')) {
960
+ this.logger.warn('Fallo en asignación de buffer de MobileNet, intentando con configuración mínima');
961
+ const fallbackOptions = {
962
+ executionProviders: ['wasm'],
963
+ graphOptimizationLevel: 'disabled',
964
+ logSeverityLevel: 4,
965
+ enableCpuMemArena: false,
966
+ enableMemPattern: false,
967
+ executionMode: 'sequential',
968
+ interOpNumThreads: 1,
969
+ intraOpNumThreads: 1,
970
+ };
971
+ this.mobileNetSession = await window.ort.InferenceSession.create(this.MOBILENET_MODEL_PATH, fallbackOptions);
972
+ }
973
+ else {
974
+ throw error;
975
+ }
976
+ }
977
+ this.logger.state('MODELO_MOBILENET_CARGADO_EXITOSAMENTE', {
978
+ sessionCreated: !!this.mobileNetSession
979
+ });
980
+ }
981
+ catch (error) {
982
+ this.logger.error('Error al cargar modelo MobileNet:', error);
983
+ throw error;
984
+ }
985
+ }
986
+ isModelLoaded() {
987
+ return this.modelLoaded && !!this.session;
988
+ }
989
+ preprocess(video) {
990
+ if (!this.preprocessCanvas || !this.preprocessCtx) {
991
+ this.initializeCanvasPool();
992
+ }
993
+ this.preprocessCtx.clearRect(0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
994
+ this.preprocessCtx.drawImage(video, 0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
995
+ const imageData = this.preprocessCtx.getImageData(0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
996
+ const [R, G, B] = [[], [], []];
997
+ const { data } = imageData;
998
+ for (let i = 0; i < data.length; i += 4) {
999
+ R.push(data[i] / 255);
1000
+ G.push(data[i + 1] / 255);
1001
+ B.push(data[i + 2] / 255);
1002
+ }
1003
+ const transposedData = new Float32Array(R.concat(G, B));
1004
+ const float16Data = new Uint16Array(transposedData.length);
1005
+ for (let i = 0; i < transposedData.length; i++) {
1006
+ float16Data[i] = this.float32ToFloat16(transposedData[i]);
1007
+ }
1008
+ return new window.ort.Tensor("float16", float16Data, [1, 3, this.INPUT_SIZE, this.INPUT_SIZE]);
1009
+ }
1010
+ async runInference(inputTensor) {
1011
+ if (!this.session) {
1012
+ throw new Error('Detection model not loaded');
1013
+ }
1014
+ const feeds = { [this.session.inputNames[0]]: inputTensor };
1015
+ const results = await this.session.run(feeds);
1016
+ const output = results[this.session.outputNames[0]].data;
1017
+ const detections = [];
1018
+ for (let i = 0; i < output.length; i += 6) {
1019
+ const [x1, y1, x2, y2, score, classId] = output.slice(i, i + 6);
1020
+ if (score > this.CONFIDENCE_THRESHOLD) {
1021
+ detections.push({
1022
+ x: x1,
1023
+ y: y1,
1024
+ w: x2 - x1,
1025
+ h: y2 - y1,
1026
+ score,
1027
+ classId
1028
+ });
1029
+ }
1030
+ }
1031
+ return detections;
1032
+ }
1033
+ async classifyDocument(canvas) {
1034
+ if (!this.mobileNetSession || !this.mobileNetClassMap) {
1035
+ this.logger.warn('Modelo MobileNet no está cargado, saltando clasificación');
1036
+ return null;
1037
+ }
1038
+ try {
1039
+ this.logger.state('CLASIFICANDO_DOCUMENTO', { timestamp: Date.now() });
1040
+ const inputTensor = this.preprocessMobileNet(canvas);
1041
+ const feeds = { [this.mobileNetSession.inputNames[0]]: inputTensor };
1042
+ const results = await this.mobileNetSession.run(feeds);
1043
+ const output = results[Object.keys(results)[0]].data;
1044
+ const maxIdx = output.reduce((bestIdx, val, idx, arr) => val > arr[bestIdx] ? idx : bestIdx, 0);
1045
+ const confidence = output[maxIdx];
1046
+ const className = this.mobileNetClassMap[maxIdx.toString()] || "unknown";
1047
+ this.logger.state('DOCUMENTO_CLASIFICADO', {
1048
+ class: className,
1049
+ confidence: confidence.toFixed(3),
1050
+ classIndex: maxIdx,
1051
+ timestamp: Date.now(),
1052
+ results
1053
+ });
1054
+ return {
1055
+ class: className,
1056
+ confidence: confidence,
1057
+ classIndex: maxIdx
1058
+ };
1059
+ }
1060
+ catch (error) {
1061
+ this.logger.error('Error al clasificar documento:', error);
1062
+ return null;
1063
+ }
1064
+ }
1065
+ checkSideAlignment(box, maskConfig) {
1066
+ const { INPUT_SIZE, ID1_ASPECT_RATIO, shouldMirrorVideo, alignmentTolerance, maskSize, videoRef } = maskConfig;
1067
+ if (!videoRef) {
1068
+ return { top: false, right: false, bottom: false, left: false };
1069
+ }
1070
+ // Get video dimensions to calculate actual mask boundaries in model space
1071
+ const videoWidth = videoRef.videoWidth;
1072
+ const videoHeight = videoRef.videoHeight;
1073
+ if (videoWidth === 0 || videoHeight === 0) {
1074
+ return { top: false, right: false, bottom: false, left: false };
1075
+ }
1076
+ // Calculate video aspect ratio
1077
+ const videoAspectRatio = videoWidth / videoHeight;
1078
+ // The model sees video stretched to 320x320, but we need to calculate where
1079
+ // the mask should be in this distorted space to match the visual mask
1080
+ // In the visual display, we calculate mask size based on the limiting dimension
1081
+ // We need to replicate this logic but account for the distortion in model space
1082
+ // Calculate the "display" dimensions (what the visual mask calculations use)
1083
+ const containerAspectRatio = 1; // Assume square container for simplicity
1084
+ let displayedVideoWidth, displayedVideoHeight;
1085
+ if (videoAspectRatio > containerAspectRatio) {
1086
+ // Video is wider - letterboxed in display
1087
+ displayedVideoWidth = INPUT_SIZE;
1088
+ displayedVideoHeight = INPUT_SIZE / videoAspectRatio;
1089
+ }
1090
+ else {
1091
+ // Video is taller - pillarboxed in display
1092
+ displayedVideoHeight = INPUT_SIZE;
1093
+ displayedVideoWidth = INPUT_SIZE * videoAspectRatio;
1094
+ }
1095
+ // Calculate mask dimensions using the same logic as updateMaskDimensions
1096
+ const maxWidthByHeight = displayedVideoHeight * ID1_ASPECT_RATIO;
1097
+ let visualMaskWidth, visualMaskHeight;
1098
+ const sizeRatio = maskSize / 100;
1099
+ if (maxWidthByHeight <= displayedVideoWidth) {
1100
+ // Video height is the limiting factor
1101
+ visualMaskHeight = displayedVideoHeight * sizeRatio;
1102
+ visualMaskWidth = visualMaskHeight * ID1_ASPECT_RATIO;
1103
+ }
1104
+ else {
1105
+ // Video width is the limiting factor
1106
+ visualMaskWidth = displayedVideoWidth * sizeRatio;
1107
+ visualMaskHeight = visualMaskWidth / ID1_ASPECT_RATIO;
1108
+ }
1109
+ // Now map these visual mask dimensions to the distorted model space
1110
+ // The model stretches video to 320x320, so we need to apply the inverse transformation
1111
+ const modelMaskWidth = visualMaskWidth * (INPUT_SIZE / displayedVideoWidth);
1112
+ const modelMaskHeight = visualMaskHeight * (INPUT_SIZE / displayedVideoHeight);
1113
+ // Calculate mask boundaries in model coordinate system (always centered in 320x320)
1114
+ const maskCenterX = INPUT_SIZE / 2;
1115
+ const maskCenterY = INPUT_SIZE / 2;
1116
+ const maskLeft = maskCenterX - (modelMaskWidth / 2);
1117
+ const maskRight = maskCenterX + (modelMaskWidth / 2);
1118
+ const maskTop = maskCenterY - (modelMaskHeight / 2);
1119
+ const maskBottom = maskCenterY + (modelMaskHeight / 2);
1120
+ // Obtener las coordenadas del documento detectado
1121
+ let docLeft = box.x;
1122
+ let docRight = box.x + box.w;
1123
+ const docTop = box.y;
1124
+ const docBottom = box.y + box.h;
1125
+ // Si el video está en modo mirror, invertir las coordenadas horizontales
1126
+ if (shouldMirrorVideo) {
1127
+ const originalDocLeft = docLeft;
1128
+ const originalDocRight = docRight;
1129
+ docLeft = INPUT_SIZE - originalDocRight;
1130
+ docRight = INPUT_SIZE - originalDocLeft;
1131
+ }
1132
+ // Tolerancia para considerar que un lado está alineado (en píxeles)
1133
+ const tolerance = alignmentTolerance;
1134
+ // Verificar alineación de esquinas más estricta
1135
+ // Una esquina está alineada solo si AMBOS bordes que la forman están alineados
1136
+ const topAligned = Math.abs(docTop - maskTop) <= tolerance;
1137
+ const rightAligned = Math.abs(docRight - maskRight) <= tolerance;
1138
+ const bottomAligned = Math.abs(docBottom - maskBottom) <= tolerance;
1139
+ const leftAligned = Math.abs(docLeft - maskLeft) <= tolerance;
1140
+ return {
1141
+ top: topAligned && leftAligned, // Esquina superior izquierda: borde top Y left alineados
1142
+ right: topAligned && rightAligned, // Esquina superior derecha: borde top Y right alineados
1143
+ bottom: bottomAligned && leftAligned, // Esquina inferior izquierda: borde bottom Y left alineados
1144
+ left: bottomAligned && rightAligned // Esquina inferior derecha: borde bottom Y right alineados
1145
+ };
1146
+ }
1147
+ isCardInFrame(box) {
1148
+ const cardCenterX = box.x + box.w / 2;
1149
+ const cardCenterY = box.y + box.h / 2;
1150
+ const frameCenterX = this.INPUT_SIZE / 2;
1151
+ const frameCenterY = this.INPUT_SIZE / 2;
1152
+ const toleranceX = 40;
1153
+ const toleranceY = 30;
1154
+ const isCentered = Math.abs(cardCenterX - frameCenterX) < toleranceX &&
1155
+ Math.abs(cardCenterY - frameCenterY) < toleranceY;
1156
+ const isGoodSize = box.w > 150 && box.w < 300 && box.h > 90 && box.h < 200;
1157
+ return isCentered && isGoodSize;
1158
+ }
1159
+ areAllSidesAligned(alignment) {
1160
+ return alignment.top && alignment.right && alignment.bottom && alignment.left;
1161
+ }
1162
+ cleanup() {
1163
+ this.cleanupCanvasPool();
1164
+ if (this.session) {
1165
+ this.session.release?.();
1166
+ this.session = undefined;
1167
+ }
1168
+ if (this.mobileNetSession) {
1169
+ this.mobileNetSession.release?.();
1170
+ this.mobileNetSession = undefined;
1171
+ }
1172
+ this.mobileNetClassMap = undefined;
1173
+ this.modelLoaded = false;
1174
+ this.logger.state('DETECCION_SERVICE_LIMPIADO', { timestamp: Date.now() });
1175
+ }
1176
+ initializeCanvasPool() {
1177
+ this.preprocessCanvas = document.createElement('canvas');
1178
+ this.preprocessCanvas.width = this.INPUT_SIZE;
1179
+ this.preprocessCanvas.height = this.INPUT_SIZE;
1180
+ this.preprocessCtx = this.preprocessCanvas.getContext('2d', {
1181
+ alpha: false,
1182
+ willReadFrequently: true
1183
+ });
1184
+ this.captureCanvas = document.createElement('canvas');
1185
+ this.logger.state('CANVAS_POOL_INICIALIZADO', {
1186
+ preprocessCanvasSize: this.INPUT_SIZE
1187
+ });
1188
+ }
1189
+ cleanupCanvasPool() {
1190
+ if (this.preprocessCanvas) {
1191
+ this.preprocessCtx = undefined;
1192
+ this.preprocessCanvas = undefined;
1193
+ }
1194
+ if (this.captureCanvas) {
1195
+ this.captureCanvas = undefined;
1196
+ }
1197
+ }
1198
+ float32ToFloat16(value) {
1199
+ const buffer = new ArrayBuffer(4);
1200
+ const view = new DataView(buffer);
1201
+ view.setFloat32(0, value, true);
1202
+ const f = view.getUint32(0, true);
1203
+ const sign = (f >> 31) & 0x1;
1204
+ const exp = (f >> 23) & 0xFF;
1205
+ const frac = f & 0x7FFFFF;
1206
+ let newExp = exp - 127 + 15;
1207
+ if (exp === 0) {
1208
+ newExp = 0;
1209
+ }
1210
+ else if (exp === 0xFF) {
1211
+ newExp = 0x1F;
1212
+ }
1213
+ else if (newExp >= 0x1F) {
1214
+ newExp = 0x1F;
1215
+ return (sign << 15) | (newExp << 10);
1216
+ }
1217
+ else if (newExp <= 0) {
1218
+ return (sign << 15);
1219
+ }
1220
+ return (sign << 15) | (newExp << 10) | (frac >> 13);
1221
+ }
1222
+ preprocessMobileNet(canvas) {
1223
+ const tempCanvas = document.createElement('canvas');
1224
+ tempCanvas.width = 224;
1225
+ tempCanvas.height = 224;
1226
+ const tempCtx = tempCanvas.getContext('2d');
1227
+ // Resize image to 224x224 for SqueezeNet (using MobileNet interface)
1228
+ tempCtx.drawImage(canvas, 0, 0, 224, 224);
1229
+ const imageData = tempCtx.getImageData(0, 0, 224, 224);
1230
+ const data = imageData.data;
1231
+ const hw = 224 * 224;
1232
+ const arr = new Float32Array(3 * hw);
1233
+ // SqueezeNet preprocessing: normalize to [0,1] range and arrange in CHW format
1234
+ for (let i = 0; i < hw; i++) {
1235
+ // Red channel
1236
+ arr[i] = data[i * 4 + 0] / 255.0;
1237
+ // Green channel
1238
+ arr[hw + i] = data[i * 4 + 1] / 255.0;
1239
+ // Blue channel
1240
+ arr[2 * hw + i] = data[i * 4 + 2] / 255.0;
1241
+ }
1242
+ return new window.ort.Tensor('float32', arr, [1, 3, 224, 224]);
1243
+ }
1244
+ }
1245
+
1246
+ class ServiceContainer {
1247
+ services = new Map();
1248
+ constructor(config) {
1249
+ this.initializeServices(config);
1250
+ }
1251
+ initializeServices(config) {
1252
+ // Initialize services in dependency order
1253
+ const eventBus = new EventBusService();
1254
+ const logger = new LoggerService(config.debug);
1255
+ const stateManager = new StateManagerService(eventBus);
1256
+ const cameraService = new CameraService(logger, eventBus, config.preferredCamera);
1257
+ const detectionService = new DetectionService(logger, config.debug, config.useDocumentClassification);
1258
+ // Register services
1259
+ this.services.set('eventBus', eventBus);
1260
+ this.services.set('logger', logger);
1261
+ this.services.set('stateManager', stateManager);
1262
+ this.services.set('cameraService', cameraService);
1263
+ this.services.set('detectionService', detectionService);
1264
+ }
1265
+ get(serviceName) {
1266
+ const service = this.services.get(serviceName);
1267
+ if (!service) {
1268
+ throw new Error(`Service ${serviceName} not found`);
1269
+ }
1270
+ return service;
1271
+ }
1272
+ getLogger() {
1273
+ return this.get('logger');
1274
+ }
1275
+ getEventBus() {
1276
+ return this.get('eventBus');
1277
+ }
1278
+ getStateManager() {
1279
+ return this.get('stateManager');
1280
+ }
1281
+ getCameraService() {
1282
+ return this.get('cameraService');
1283
+ }
1284
+ getDetectionService() {
1285
+ return this.get('detectionService');
1286
+ }
1287
+ updateConfig(config) {
1288
+ if (config.debug !== undefined) {
1289
+ const logger = this.services.get('logger');
1290
+ logger.setDebugMode(config.debug);
1291
+ }
1292
+ }
1293
+ cleanup() {
1294
+ // Cleanup services in reverse order
1295
+ this.getDetectionService().cleanup();
1296
+ this.getEventBus().clear();
1297
+ this.services.clear();
1298
+ }
1299
+ }
1300
+
1301
+ const myComponentCss = ":host{display:block;width:100%;height:100%;font-family:system-ui, -apple-system, sans-serif;color:#1a1a1a}.detector-container{display:flex;flex-direction:column;align-items:center;width:100%;height:100%}h1{font-size:24px;font-weight:500;color:#333;margin:0 0 24px 0}.video-container{position:relative;width:100%;height:100%;background:#333;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden}video,.detection-overlay{position:absolute;width:100%;height:100%;border-radius:8px}video.mirror,.detection-overlay.mirror{transform:rotateY(180deg)}.detection-overlay{z-index:1;pointer-events:none}video{z-index:0}.detection-box{transition:opacity 0.2s ease}.status{margin-top:16px;font-size:12px;color:#666}.overlay-mask{position:absolute;top:0;left:0;width:100%;height:100%;z-index:10;display:block;pointer-events:none}.card-outline{position:absolute;top:var(--mask-center-y, 50%);left:var(--mask-center-x, 50%);transform:translate(-50%, -50%);width:var(--mask-width, 88%);height:var(--mask-height, 55%);border:none;border-radius:4px;background:transparent;opacity:0.8}.side{position:absolute;background:#999;transition:background-color 0.3s ease;border-radius:1px}.side-top.aligned{border-left-color:#28a745;border-top-color:#28a745}.side-right.aligned{border-right-color:#28a745;border-top-color:#28a745}.side-bottom.aligned{border-left-color:#28a745;border-bottom-color:#28a745}.side-left.aligned{border-right-color:#28a745;border-bottom-color:#28a745}.side-top{top:-3px;left:-3px;width:30px;height:30px;border-left:6px solid #ffffff;border-top:6px solid #ffffff;background:transparent}.side-right{top:-3px;right:-3px;width:30px;height:30px;border-right:6px solid #ffffff;border-top:6px solid #ffffff;background:transparent}.side-bottom{bottom:-3px;left:-3px;width:30px;height:30px;border-left:6px solid #ffffff;border-bottom:6px solid #ffffff;background:transparent}.side-left{bottom:-3px;right:-3px;width:30px;height:30px;border-right:6px solid #ffffff;border-bottom:6px solid #ffffff;background:transparent}.guide-text{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;font-size:14px;font-weight:600;text-align:center;white-space:normal;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(12px);padding:12px 20px;border-radius:8px;max-width:300px;z-index:20;border:1px solid rgba(255, 255, 255, 0.1)}.quality-indicator{position:absolute;top:20px;right:20px;display:flex;align-items:center;gap:8px;background:rgba(0, 0, 0, 0.8);padding:8px 12px;border-radius:20px;z-index:25;transition:all 0.3s ease}.quality-indicator.warning{background:rgba(255, 152, 0, 0.9)}.quality-indicator.good{background:rgba(76, 175, 80, 0.9)}.quality-score{color:#fff;font-size:12px;font-weight:600;min-width:30px;text-align:center}.quality-status{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;color:#fff;transition:all 0.3s ease}.quality-status.ready{background:#4CAF50}.quality-status.not-ready{background:#f44336}.card-outline.quality-warning{animation:qualityWarning 1s ease-in-out infinite alternate}@keyframes qualityWarning{0%{box-shadow:0 0 0 3px rgba(255, 152, 0, 0.6)}100%{box-shadow:0 0 0 6px rgba(255, 152, 0, 0.3)}}.capture-animation{position:absolute;top:0;left:0;width:100%;height:100%;background:#fff;opacity:0;z-index:30;pointer-events:none;animation:captureFlash 0.6s ease-out}@keyframes captureFlash{0%{opacity:0}15%{opacity:0.8}30%{opacity:0}45%{opacity:0.4}60%{opacity:0}100%{opacity:0}}.card-outline.capturing{animation:pulseGreen 0.6s ease-out}@keyframes pulseGreen{0%{border-color:#28a745;box-shadow:0 0 0 4px rgba(40, 167, 69, 0)}50%{border-color:#28a745;box-shadow:0 0 0 8px rgba(40, 167, 69, 0.6)}100%{border-color:#28a745;box-shadow:0 0 0 4px rgba(40, 167, 69, 0)}}.flip-animation{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);z-index:35;pointer-events:none;opacity:0;animation:showFlipInstruction 3s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center}.id-card-icon{width:80px;height:50px;background:linear-gradient(135deg, #6c757d 0%, #495057 100%);border-radius:8px;position:relative;animation:flipCard 2s ease-in-out infinite;box-shadow:0 4px 12px rgba(0, 0, 0, 0.3)}.id-card-icon::before{content:'';position:absolute;top:8px;left:8px;width:16px;height:12px;background:rgba(255, 255, 255, 0.9);border-radius:2px}.id-card-icon::after{content:'';position:absolute;top:25px;left:8px;width:64px;height:3px;background:rgba(255, 255, 255, 0.7);border-radius:1px;box-shadow:0 6px 0 rgba(255, 255, 255, 0.7), 0 12px 0 rgba(255, 255, 255, 0.7)}.flip-text{margin-top:12px;color:#fff;font-size:14px;font-weight:600;text-align:center;background:rgba(128, 128, 128, 0.8);padding:12px 20px;border-radius:20px;max-width:300px}@keyframes showFlipInstruction{0%{opacity:0;transform:translate(-50%, -50%) scale(0.8)}15%{opacity:1;transform:translate(-50%, -50%) scale(1)}85%{opacity:1;transform:translate(-50%, -50%) scale(1)}100%{opacity:0;transform:translate(-50%, -50%) scale(0.8)}}@keyframes flipCard{0%{transform:rotateY(0deg)}50%{transform:rotateY(180deg)}100%{transform:rotateY(360deg)}}.success-animation{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);z-index:35;pointer-events:none;opacity:0;animation:showSuccessMessage 3s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center}.check-icon{width:80px;height:80px;background:linear-gradient(135deg, #6c757d 0%, #495057 100%);border-radius:50%;position:relative;animation:bounceSuccess 0.6s ease-out;box-shadow:0 4px 16px rgba(108, 117, 125, 0.4);display:flex;align-items:center;justify-content:center}.check-icon::before{content:'';position:absolute;width:20px;height:35px;border:4px solid #fff;border-top:none;border-left:none;transform:rotate(45deg);animation:drawCheck 0.4s ease-out 0.2s both}.success-text{margin-top:16px;color:#fff;font-size:14px;font-weight:600;text-align:center;background:rgba(128, 128, 128, 0.8);padding:12px 20px;border-radius:20px;max-width:300px;animation:fadeInUp 0.5s ease-out 0.4s both}@keyframes showSuccessMessage{0%{opacity:0;transform:translate(-50%, -50%) scale(0.5)}15%{opacity:1;transform:translate(-50%, -50%) scale(1)}85%{opacity:1;transform:translate(-50%, -50%) scale(1)}100%{opacity:0;transform:translate(-50%, -50%) scale(0.9)}}@keyframes bounceSuccess{0%{transform:scale(0)}50%{transform:scale(1.1)}100%{transform:scale(1)}}@keyframes drawCheck{0%{width:0;height:0}50%{width:20px;height:0}100%{width:20px;height:35px}}@keyframes fadeInUp{0%{opacity:0;transform:translateY(20px)}100%{opacity:1;transform:translateY(0)}}.skip-section{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:25;display:flex;flex-direction:column;align-items:center;width:100%;max-width:300px}.skip-explanation{background:rgba(0, 0, 0, 0.5);color:#ffffff;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:500;text-align:center;margin-bottom:12px;box-shadow:0 2px 8px rgba(0, 0, 0, 0.2);backdrop-filter:blur(12px);border:1px solid rgba(255, 255, 255, 0.1);animation:fadeIn 0.3s ease-in-out}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.skip-button{pointer-events:auto;background:#fff;color:#333;border:none;border-radius:25px;padding:12px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.3s ease}.skip-button:hover{background:#f8f9fa}.skip-button:active{background:#e9ecef;transform:translateY(1px)}.camera-controls{position:absolute;top:16px;right:16px;z-index:25;display:flex;gap:8px;pointer-events:auto}.camera-selector-button{height:32px;padding:0 10px;border:none;border-radius:8px;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(12px);color:#ffffff;font-size:12px;font-weight:500;cursor:pointer;transition:all 0.2s ease;display:flex;align-items:center;justify-content:center;border:1px solid rgba(255, 255, 255, 0.1);white-space:nowrap;min-width:fit-content}.camera-selector-button:hover{background:rgba(0, 0, 0, 0.8);border-color:rgba(255, 255, 255, 0.2);transform:translateY(-1px)}.camera-selector-button:active{transform:translateY(0);background:rgba(0, 0, 0, 0.9)}.camera-selector-button:disabled,.camera-selector-button.loading{opacity:0.7;cursor:not-allowed;pointer-events:none}.camera-selector-button:disabled:hover,.camera-selector-button.loading:hover{transform:none;background:rgba(0, 0, 0, 0.6);border-color:rgba(255, 255, 255, 0.1)}.button-spinner{width:16px;height:16px;border:2px solid rgba(255, 255, 255, 0.3);border-top:2px solid #ffffff;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.camera-selector-dropdown{position:absolute;top:56px;right:16px;z-index:30;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(20px);border-radius:12px;min-width:260px;max-width:300px;border:1px solid rgba(255, 255, 255, 0.1);overflow:hidden;pointer-events:auto;animation:slideInFromTop 0.3s ease-out}@keyframes slideInFromTop{0%{opacity:0;transform:translateY(-8px) scale(0.95)}100%{opacity:1;transform:translateY(0) scale(1)}}.camera-selector-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid rgba(255, 255, 255, 0.1)}.camera-selector-header span{font-weight:500;color:#ffffff;font-size:13px;opacity:0.9}.close-selector{width:20px;height:20px;border:none;background:none;color:rgba(255, 255, 255, 0.7);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all 0.2s ease}.close-selector:hover{background:rgba(255, 255, 255, 0.1);color:#ffffff}.camera-list{padding:4px 0;max-height:240px;overflow-y:auto}.camera-option{width:100%;padding:8px 12px;border:none;background:none;text-align:left;cursor:pointer;transition:all 0.2s ease;display:flex;justify-content:space-between;align-items:center;color:rgba(255, 255, 255, 0.9)}.camera-option:hover{background:rgba(255, 255, 255, 0.08)}.camera-option.selected{background:rgba(255, 255, 255, 0.12);color:#ffffff}.camera-label{font-size:13px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:8px;font-weight:400}.selected-indicator{font-size:14px;color:#ffffff;opacity:0.9}.device-info{padding:6px 12px;border-top:1px solid rgba(255, 255, 255, 0.1);background:rgba(255, 255, 255, 0.05)}.device-info small{color:rgba(255, 255, 255, 0.6);font-size:11px;text-transform:capitalize;font-weight:400}@media (max-width: 480px){.camera-controls{top:12px;right:12px;gap:6px}.camera-selector-button{height:36px;padding:0 12px;font-size:11px;border-radius:6px}.camera-selector-dropdown{right:12px;top:48px;min-width:240px;max-width:calc(100vw - 24px)}.camera-selector-header{padding:6px 10px}.camera-option{padding:6px 10px}.device-info{padding:4px 10px}}.watermark{position:absolute;bottom:12px;right:12px;z-index:15;pointer-events:none;opacity:0.7}.watermark img{height:24px;width:auto;filter:drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))}.component-status{position:absolute;top:16px;left:16px;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(20px);border-radius:12px;border:1px solid rgba(255, 255, 255, 0.1);display:flex;align-items:center;gap:6px;padding:6px 10px;z-index:40;height:32px;width:fit-content;animation:slideInFromTop 0.3s ease-out}.status-spinner{width:16px;height:16px;border:1px solid rgba(255, 255, 255, 0.3);border-top:1px solid #ffffff;border-radius:50%;animation:statusSpin 1s linear infinite;flex-shrink:0}.status-content{display:flex;flex-direction:column;gap:1px}.status-message{color:#ffffff;font-size:12px;font-weight:500;margin:0}.status-description{color:rgba(255, 255, 255, 0.7);font-size:10px;line-height:1.2;margin:0}@keyframes statusSpin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@media (max-width: 480px){.component-status{top:12px;left:12px;padding:4px 8px;gap:4px;height:28px;border-radius:8px}.status-spinner{width:14px;height:14px}.status-message{font-size:11px}.status-description{font-size:9px}}.loading-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.85);backdrop-filter:blur(4px);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30;border-radius:8px}.loading-spinner{width:50px;height:50px;border:3px solid rgba(255, 255, 255, 0.15);border-top:3px solid #28a745;border-radius:50%;animation:spin 1s ease-in-out infinite;margin-bottom:16px}.loading-text{color:#ffffff;font-size:14px;font-weight:500;text-align:center;opacity:0.9;margin-bottom:4px}.loading-description{color:rgba(255, 255, 255, 0.7);font-size:12px;text-align:center;opacity:0.8}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.performance-monitor{position:absolute;top:70px;left:16px;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(20px);border-radius:12px;border:1px solid rgba(255, 255, 255, 0.1);z-index:35;width:fit-content;animation:slideInFromLeft 0.3s ease-out;transition:all 0.3s ease}@keyframes slideInFromLeft{0%{opacity:0;transform:translateX(-20px) scale(0.95)}100%{opacity:1;transform:translateX(0) scale(1)}}.performance-expanded{padding:6px 10px}.metrics-row{display:flex;gap:8px;margin-bottom:4px}.metrics-row:last-child{margin-bottom:0}.metric-compact{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:50px}.metric-label{font-size:9px;color:rgba(255, 255, 255, 0.6);font-weight:500;text-transform:uppercase;letter-spacing:0.3px}.metric-value{font-size:10px;font-weight:600;font-family:'Monaco', 'Menlo', 'Consolas', monospace;padding:2px 4px;border-radius:3px;text-align:center;white-space:nowrap}.metric-value.good{color:#4ade80;background:rgba(74, 222, 128, 0.1);border:1px solid rgba(74, 222, 128, 0.2)}.metric-value.warning{color:#fbbf24;background:rgba(251, 191, 36, 0.1);border:1px solid rgba(251, 191, 36, 0.2)}.metric-value.danger{color:#f87171;background:rgba(248, 113, 113, 0.1);border:1px solid rgba(248, 113, 113, 0.2)}@media (max-width: 480px){.performance-monitor{top:60px;left:12px;width:fit-content}.performance-expanded{padding:4px 8px}.metrics-row{gap:6px;margin-bottom:3px}.metric-compact{min-width:45px;gap:1px}.metric-label{font-size:8px}.metric-value{font-size:9px;padding:1px 3px}}.quality-monitor{position:absolute;top:200px;left:16px;background:rgba(0, 0, 0, 0.25);backdrop-filter:blur(20px);border-radius:12px;border:1px solid rgba(255, 255, 255, 0.1);z-index:35;width:fit-content;animation:slideInFromLeft 0.3s ease-out;transition:all 0.3s ease}.quality-text{padding:6px 10px;font-size:11px;color:white;font-family:'Monaco', 'Menlo', 'Consolas', monospace;line-height:1.4}.quality-fail{color:#ff9999}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.7}}.metric-value.danger{animation:pulse 2s infinite}@media (max-width: 480px){.quality-monitor{top:160px;left:12px}.quality-text{padding:4px 8px;font-size:10px}}";
1302
+
1303
+ const JaakStamps = class {
1304
+ constructor(hostRef) {
1305
+ index.registerInstance(this, hostRef);
1306
+ this.captureCompleted = index.createEvent(this, "captureCompleted");
1307
+ this.isReady = index.createEvent(this, "isReady");
1308
+ }
1309
+ get el() { return index.getElement(this); }
1310
+ debug = false;
1311
+ alignmentTolerance = 15;
1312
+ maskSize = 80;
1313
+ cropMargin = 20;
1314
+ useDocumentClassification = false;
1315
+ preferredCamera = 'auto';
1316
+ captureDelay = 1500;
1317
+ enableBackDocumentTimer = false;
1318
+ backDocumentTimerDuration = 20;
1319
+ captureCompleted;
1320
+ isReady;
1321
+ // State derived from services
1322
+ detectionBoxes = [];
1323
+ sideAlignment = {
1324
+ top: false, right: false, bottom: false, left: false
1325
+ };
1326
+ isMaskReady = false;
1327
+ shouldMirrorVideo = true;
1328
+ showCameraSelector = false;
1329
+ isSwitchingCamera = false;
1330
+ hasDocumentDetected = false;
1331
+ currentStatus = {
1332
+ message: 'Inicializando componente...',
1333
+ description: 'Configurando servicios y cargando recursos',
1334
+ type: 'initializing',
1335
+ isInitialized: false
1336
+ };
1337
+ performanceData = {
1338
+ fps: 0,
1339
+ inferenceTime: 0,
1340
+ memoryUsage: 0,
1341
+ onnxLoadTime: 0,
1342
+ frameProcessingTime: 0,
1343
+ totalDetections: 0,
1344
+ successfulDetections: 0,
1345
+ detectionRate: 0
1346
+ };
1347
+ backDocumentTimerRemaining = 0;
1348
+ // Services
1349
+ serviceContainer;
1350
+ logger;
1351
+ eventBus;
1352
+ stateManager;
1353
+ cameraService;
1354
+ detectionService;
1355
+ // UI References
1356
+ videoRef;
1357
+ detectionContainer;
1358
+ videoStream;
1359
+ animationId;
1360
+ // Detection state
1361
+ lastDetectedBox;
1362
+ startTime;
1363
+ hasScreenshotTaken = false;
1364
+ alignmentStartTime;
1365
+ alignmentTimer;
1366
+ backDocumentTimer;
1367
+ // Performance monitoring
1368
+ performanceMetrics = {
1369
+ fps: 0,
1370
+ inferenceTime: 0,
1371
+ memoryUsage: 0,
1372
+ cpuUsage: 0,
1373
+ onnxLoadTime: 0,
1374
+ frameProcessingTime: 0,
1375
+ totalDetections: 0,
1376
+ successfulDetections: 0,
1377
+ detectionRate: 0,
1378
+ lastUpdateTime: 0
1379
+ };
1380
+ performanceUpdateInterval;
1381
+ // Performance optimization
1382
+ frameSkipCounter = 0;
1383
+ FRAME_SKIP = 2;
1384
+ consecutiveFailures = 0;
1385
+ MAX_FAILURES = 30;
1386
+ lastInferenceTime = 0;
1387
+ MIN_INFERENCE_INTERVAL = 50;
1388
+ async componentDidLoad() {
1389
+ this.updateStatus('Iniciando servicios...', 'Configurando módulos internos', 'initializing');
1390
+ await this.initializeServices();
1391
+ this.updateStatus('Configurando eventos...', 'Preparando comunicación entre servicios', 'initializing');
1392
+ await this.setupEventListeners();
1393
+ this.updateStatus('Inicializando cámara...', 'Detectando dispositivos disponibles', 'initializing');
1394
+ await this.initializeComponent();
1395
+ if (this.debug) {
1396
+ this.initializePerformanceMonitor();
300
1397
  }
301
1398
  }
302
- async switchCamera(cameraId) {
303
- if (!this.isVideoActive || this.selectedCameraId === cameraId)
304
- return;
305
- try {
306
- // Check if the selected camera is still available
307
- const selectedCamera = this.availableCameras.find(cam => cam.deviceId === cameraId);
308
- if (!selectedCamera) {
309
- this.debugLog('❌ Selected camera not found, re-enumerating...');
310
- await this.enumerateAndDetectCameras();
311
- return;
312
- }
313
- // Show loading state
314
- this.statusMessage = "Cambiando cámara...";
315
- this.statusColor = "#007bff";
316
- // Stop current stream
317
- if (this.videoStream) {
318
- this.videoStream.getTracks().forEach(track => track.stop());
319
- }
320
- // Update selected camera
321
- this.selectedCameraId = cameraId;
322
- // Update facing mode preference
323
- const isRearCamera = selectedCamera.label.toLowerCase().includes('back') ||
324
- selectedCamera.label.toLowerCase().includes('rear') ||
325
- selectedCamera.label.toLowerCase().includes('environment');
326
- this.preferredCameraFacing = isRearCamera ? 'environment' : 'user';
327
- // Save preference
328
- this.saveCameraPreference();
329
- // Setup new camera with error handling
330
- await this.setupCameraWithRetry();
331
- this.debugLog('🔄 Switched to camera:', selectedCamera.label);
332
- }
333
- catch (error) {
334
- this.debugLog('❌ Error switching camera:', error);
335
- this.handleCameraPermissionError(error);
336
- }
1399
+ async initializeServices() {
1400
+ const config = {
1401
+ debug: this.debug,
1402
+ alignmentTolerance: this.alignmentTolerance,
1403
+ maskSize: this.maskSize,
1404
+ cropMargin: this.cropMargin,
1405
+ useDocumentClassification: this.useDocumentClassification,
1406
+ preferredCamera: this.preferredCamera,
1407
+ captureDelay: this.captureDelay
1408
+ };
1409
+ this.serviceContainer = new ServiceContainer(config);
1410
+ this.logger = this.serviceContainer.getLogger();
1411
+ this.eventBus = this.serviceContainer.getEventBus();
1412
+ this.stateManager = this.serviceContainer.getStateManager();
1413
+ this.cameraService = this.serviceContainer.getCameraService();
1414
+ this.detectionService = this.serviceContainer.getDetectionService();
1415
+ this.logger.state('SERVICIOS_INICIALIZADOS', { timestamp: Date.now() });
337
1416
  }
338
- async setupCameraWithRetry(maxRetries = 3) {
339
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
340
- try {
341
- await this.setupCamera();
342
- return; // Success
343
- }
344
- catch (error) {
345
- this.debugLog(`❌ Camera setup attempt ${attempt} failed:`, error);
346
- if (attempt === maxRetries) {
347
- // Last attempt failed, handle the error
348
- this.handleCameraPermissionError(error);
349
- throw error;
350
- }
351
- // Wait before retrying
352
- await new Promise(resolve => setTimeout(resolve, 500 * attempt));
353
- }
354
- }
1417
+ async setupEventListeners() {
1418
+ this.eventBus.on('state-changed', (data) => {
1419
+ this.handleStateChange(data);
1420
+ });
1421
+ this.eventBus.on('camera-changed', (cameraId) => {
1422
+ this.logger.state('CAMARA_CAMBIADA_EVENT', { cameraId });
1423
+ });
1424
+ this.eventBus.on('error', (error) => {
1425
+ this.logger.error('Error en servicio:', error);
1426
+ });
355
1427
  }
356
- toggleCameraSelector() {
357
- this.showCameraSelector = !this.showCameraSelector;
358
- this.debugLog('📹 Camera selector toggled:', {
359
- showCameraSelector: this.showCameraSelector,
360
- isMultipleCamerasAvailable: this.isMultipleCamerasAvailable,
361
- availableCameras: this.availableCameras.length,
362
- isVideoActive: this.isVideoActive
1428
+ async initializeComponent() {
1429
+ this.logger.state('COMPONENTE_INICIALIZANDO', {
1430
+ debug: this.debug,
1431
+ maskSize: this.maskSize,
1432
+ cropMargin: this.cropMargin,
1433
+ useDocumentClassification: this.useDocumentClassification,
1434
+ preferredCamera: this.preferredCamera,
1435
+ captureDelay: this.captureDelay
363
1436
  });
1437
+ this.validateProps();
1438
+ if (this.debug) {
1439
+ this.stateManager.updateCaptureState({ isLoading: true });
1440
+ await new Promise(resolve => setTimeout(resolve, 500));
1441
+ }
1442
+ this.updateStatus('Detectando cámaras...', 'Buscando dispositivos de captura', 'initializing');
1443
+ await this.cameraService.detectDeviceType();
1444
+ await this.cameraService.enumerateDevices();
1445
+ this.updateStatus('Cargando modelo IA...', 'Preparando reconocimiento de documentos', 'initializing');
1446
+ await this.loadOnnxRuntime();
1447
+ this.initializeResizeObserver();
364
1448
  }
365
- async flipCamera() {
366
- if (!this.isMultipleCamerasAvailable)
367
- return;
368
- const currentIndex = this.availableCameras.findIndex(camera => camera.deviceId === this.selectedCameraId);
369
- const nextIndex = (currentIndex + 1) % this.availableCameras.length;
370
- const nextCamera = this.availableCameras[nextIndex];
371
- await this.switchCamera(nextCamera.deviceId);
1449
+ validateProps() {
1450
+ if (this.maskSize < 50 || this.maskSize > 100) {
1451
+ this.logger.warn(`Propiedad maskSize inválida. Valor: ${this.maskSize}, esperado: 50-100. Usando valor por defecto: 90`);
1452
+ this.maskSize = 90;
1453
+ }
1454
+ if (this.cropMargin < 0 || this.cropMargin > 100) {
1455
+ this.logger.warn(`Propiedad cropMargin inválida. Valor: ${this.cropMargin}, esperado: 0-100. Usando valor por defecto: 0`);
1456
+ this.cropMargin = 0;
1457
+ }
1458
+ const validOptions = ['auto', 'front', 'back'];
1459
+ if (!validOptions.includes(this.preferredCamera)) {
1460
+ this.logger.warn(`Propiedad preferredCamera inválida. Valor: ${this.preferredCamera}, esperado: ${validOptions.join(', ')}. Usando valor por defecto: 'auto'`);
1461
+ this.preferredCamera = 'auto';
1462
+ }
1463
+ if (this.captureDelay < 0 || this.captureDelay > 10000) {
1464
+ this.logger.warn(`Propiedad captureDelay inválida. Valor: ${this.captureDelay}, esperado: 0-10000. Usando valor por defecto: 1500`);
1465
+ this.captureDelay = 1500;
1466
+ }
372
1467
  }
373
- async componentDidLoad() {
374
- this.validateMaskSize();
375
- this.validateCropMargin();
376
- this.validatePreferredCamera();
377
- await this.detectDeviceTypeAndCameras();
1468
+ async loadOnnxRuntime() {
378
1469
  if (!window.ort) {
1470
+ this.updateStatus('Descargando librerías...', 'Obteniendo recursos de reconocimiento', 'initializing');
379
1471
  const script = document.createElement('script');
380
1472
  script.src = 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js';
381
1473
  document.head.appendChild(script);
382
- // Wait for ONNX runtime to load before emitting ready event
383
- script.onload = () => {
384
- this.emitReadyEvent();
385
- };
1474
+ await new Promise((resolve) => {
1475
+ script.onload = () => {
1476
+ setTimeout(() => {
1477
+ this.finalizeInitialization();
1478
+ resolve(undefined);
1479
+ }, 300);
1480
+ };
1481
+ });
386
1482
  }
387
1483
  else {
388
- // ONNX runtime already loaded
389
- this.emitReadyEvent();
1484
+ setTimeout(() => {
1485
+ this.finalizeInitialization();
1486
+ }, 300);
390
1487
  }
391
- // Initialize canvas pool for better performance
392
- this.initializeCanvasPool();
393
- this.setupResizeObserver();
394
1488
  }
395
- setupResizeObserver() {
396
- if ('ResizeObserver' in window && this.canvasRef) {
1489
+ finalizeInitialization() {
1490
+ this.stateManager.updateCaptureState({ isLoading: false });
1491
+ this.currentStatus = {
1492
+ message: 'Listo para capturar',
1493
+ description: '',
1494
+ type: 'ready',
1495
+ isInitialized: true
1496
+ };
1497
+ this.emitReadyEvent();
1498
+ }
1499
+ updateStatus(message, description, type = 'loading') {
1500
+ this.currentStatus = {
1501
+ message,
1502
+ description,
1503
+ type,
1504
+ isInitialized: this.currentStatus.isInitialized
1505
+ };
1506
+ }
1507
+ emitReadyEvent() {
1508
+ const isDocumentReady = !!window.ort && this.detectionService.isModelLoaded();
1509
+ this.isReady.emit(isDocumentReady);
1510
+ this.logger.state('COMPONENTE_LISTO', {
1511
+ ortLibraryLoaded: !!window.ort,
1512
+ modelPreloaded: this.detectionService.isModelLoaded(),
1513
+ isReady: isDocumentReady
1514
+ });
1515
+ }
1516
+ handleStateChange(data) {
1517
+ }
1518
+ initializeResizeObserver() {
1519
+ if ('ResizeObserver' in window && this.detectionContainer) {
397
1520
  const resizeObserver = new ResizeObserver(() => {
398
1521
  this.handleResize();
399
1522
  });
400
- resizeObserver.observe(this.canvasRef.parentElement);
1523
+ resizeObserver.observe(this.detectionContainer.parentElement);
401
1524
  }
402
1525
  }
403
1526
  handleResize() {
404
- if (this.canvasRef) {
405
- const container = this.canvasRef.parentElement;
1527
+ if (this.detectionContainer) {
1528
+ const container = this.detectionContainer.parentElement;
406
1529
  const rect = container.getBoundingClientRect();
407
- // Set canvas size to match container
408
- this.canvasRef.width = rect.width;
409
- this.canvasRef.height = rect.height;
410
- // Update mask positioning based on container and video dimensions
411
1530
  this.updateMaskDimensions(rect);
412
- this.debugLog('📐 Canvas resized:', { width: rect.width, height: rect.height });
1531
+ this.logger.debug('Container redimensionado:', { width: rect.width, height: rect.height });
413
1532
  }
414
1533
  }
415
1534
  updateMaskDimensions(containerRect) {
@@ -426,32 +1545,27 @@ const JaakStamps = class {
426
1545
  let displayedVideoWidth, displayedVideoHeight;
427
1546
  let videoOffsetX = 0, videoOffsetY = 0;
428
1547
  if (videoAspectRatio > containerAspectRatio) {
429
- // Video is wider - letterboxed (black bars top/bottom)
430
1548
  displayedVideoWidth = containerRect.width;
431
1549
  displayedVideoHeight = containerRect.width / videoAspectRatio;
432
1550
  videoOffsetY = (containerRect.height - displayedVideoHeight) / 2;
433
1551
  }
434
1552
  else {
435
- // Video is taller - pillarboxed (black bars left/right)
436
1553
  displayedVideoHeight = containerRect.height;
437
1554
  displayedVideoWidth = containerRect.height * videoAspectRatio;
438
1555
  videoOffsetX = (containerRect.width - displayedVideoWidth) / 2;
439
1556
  }
440
- // Calculate maximum possible mask size while preserving exact ID-1 aspect ratio
441
- // Determine the limiting dimension based on video orientation and ID-1 proportions
442
- // Calculate what would be the max width if limited by video height
443
- const maxWidthByHeight = displayedVideoHeight * this.ID1_ASPECT_RATIO;
1557
+ // Calculate mask dimensions with ID-1 aspect ratio
1558
+ const ID1_ASPECT_RATIO = 85.60 / 53.98;
1559
+ const maxWidthByHeight = displayedVideoHeight * ID1_ASPECT_RATIO;
444
1560
  let maskWidthInVideo, maskHeightInVideo;
445
1561
  const sizeRatio = this.maskSize / 100;
446
1562
  if (maxWidthByHeight <= displayedVideoWidth) {
447
- // Video height is the limiting factor
448
1563
  maskHeightInVideo = displayedVideoHeight * sizeRatio;
449
- maskWidthInVideo = maskHeightInVideo * this.ID1_ASPECT_RATIO;
1564
+ maskWidthInVideo = maskHeightInVideo * ID1_ASPECT_RATIO;
450
1565
  }
451
1566
  else {
452
- // Video width is the limiting factor
453
1567
  maskWidthInVideo = displayedVideoWidth * sizeRatio;
454
- maskHeightInVideo = maskWidthInVideo / this.ID1_ASPECT_RATIO;
1568
+ maskHeightInVideo = maskWidthInVideo / ID1_ASPECT_RATIO;
455
1569
  }
456
1570
  // Convert to percentages of the container
457
1571
  const maskWidthPercent = (maskWidthInVideo / containerRect.width) * 100;
@@ -467,9 +1581,8 @@ const JaakStamps = class {
467
1581
  this.el.style.setProperty('--mask-height', `${maskHeightPercent}%`);
468
1582
  this.el.style.setProperty('--mask-center-x', `${videoCenterXPercent}%`);
469
1583
  this.el.style.setProperty('--mask-center-y', `${videoCenterYPercent}%`);
470
- // Mark mask as ready now that dimensions are calculated
471
1584
  this.isMaskReady = true;
472
- this.debugLog('🎯 Mask dimensions updated:', {
1585
+ this.logger.state('DIMENSIONES_MASCARA_ACTUALIZADAS', {
473
1586
  video: { width: videoWidth, height: videoHeight },
474
1587
  displayed: { width: displayedVideoWidth, height: displayedVideoHeight },
475
1588
  mask: { widthPercent: maskWidthPercent, heightPercent: maskHeightPercent },
@@ -477,46 +1590,16 @@ const JaakStamps = class {
477
1590
  offset: { x: videoOffsetX, y: videoOffsetY }
478
1591
  });
479
1592
  }
480
- initializeCanvasPool() {
481
- // Preprocess canvas - reused for every inference
482
- this.preprocessCanvas = document.createElement('canvas');
483
- this.preprocessCanvas.width = this.INPUT_SIZE;
484
- this.preprocessCanvas.height = this.INPUT_SIZE;
485
- this.preprocessCtx = this.preprocessCanvas.getContext('2d', {
486
- alpha: false, // No transparency needed
487
- willReadFrequently: true // Optimize for frequent getImageData calls
488
- });
489
- // Capture canvas - reused for screenshots
490
- this.captureCanvas = document.createElement('canvas');
491
- this.captureCtx = this.captureCanvas.getContext('2d', {
492
- alpha: false
493
- });
494
- this.debugLog('🎨 Canvas pool initialized for performance');
495
- }
496
- disconnectedCallback() {
497
- this.cleanup();
498
- }
1593
+ // PUBLIC METHODS
499
1594
  async getCapturedImages() {
500
- if (this.captureStep !== 'completed') {
1595
+ const state = this.stateManager.getCaptureState();
1596
+ if (state.step !== 'completed') {
501
1597
  throw new Error('El proceso de captura no ha sido completado');
502
1598
  }
503
- return {
504
- front: {
505
- fullFrame: this.capturedFullFrame,
506
- cropped: this.capturedCroppedId
507
- },
508
- back: {
509
- fullFrame: this.capturedBackFullFrame,
510
- cropped: this.capturedBackCroppedId
511
- },
512
- metadata: {
513
- hasBackCapture: !!(this.capturedBackFullFrame && this.capturedBackCroppedId),
514
- totalImages: (this.capturedBackFullFrame && this.capturedBackCroppedId) ? 4 : 2
515
- }
516
- };
1599
+ return this.stateManager.getCapturedImages();
517
1600
  }
518
1601
  async isProcessCompleted() {
519
- return this.captureStep === 'completed';
1602
+ return this.stateManager.isProcessCompleted();
520
1603
  }
521
1604
  async startCapture() {
522
1605
  await this.startDetection();
@@ -527,452 +1610,256 @@ const JaakStamps = class {
527
1610
  async resetCapture() {
528
1611
  this.resetDetection();
529
1612
  }
530
- async getStatus() {
531
- return {
532
- isVideoActive: this.isVideoActive,
533
- captureStep: this.captureStep,
534
- statusMessage: this.statusMessage,
535
- hasImages: !!(this.capturedFullFrame || this.capturedBackFullFrame),
536
- isProcessCompleted: this.captureStep === 'completed',
537
- isModelPreloaded: this.isModelPreloaded
538
- };
539
- }
540
- async preloadModel() {
541
- if (this.isModelPreloaded || this.session) {
542
- this.debugLog('🚀 Model already preloaded or session exists');
543
- return { success: true, message: 'Model already loaded' };
544
- }
545
- try {
546
- this.isLoading = true;
547
- this.statusMessage = "Precargando modelos...";
548
- this.statusColor = "#007bff";
549
- const modelPath = this.MODEL_PATH;
550
- this.debugLog('🤖 Preloading detection model:', modelPath);
551
- // Configure ONNX Runtime with device-specific optimizations
552
- const sessionOptions = this.getSessionOptions();
553
- this.session = await window.ort.InferenceSession.create(modelPath, sessionOptions);
554
- // Preload MobileNet model and classes only if classification is enabled
555
- if (this.useDocumentClassification) {
556
- await this.loadMobileNetModel();
557
- }
558
- this.isModelPreloaded = true;
559
- this.isLoading = false;
560
- this.statusMessage = "Modelos precargados. Listo para comenzar detección";
561
- this.statusColor = "#28a745";
562
- this.emitReadyEvent();
563
- this.debugLog('✅ Models preloaded successfully');
564
- return { success: true, message: 'Models preloaded successfully' };
565
- }
566
- catch (error) {
567
- this.debugLog('❌ Error preloading models:', error);
568
- this.isLoading = false;
569
- this.statusMessage = "Error al precargar los modelos";
570
- this.statusColor = "#ff6b6b";
571
- return { success: false, error: error.message };
572
- }
573
- }
574
1613
  async skipBackCapture() {
575
- if (this.captureStep === 'back' && this.capturedFullFrame) {
1614
+ const captureState = this.stateManager.getCaptureState();
1615
+ const capturedImages = this.stateManager.getCapturedImages();
1616
+ if (captureState.step === 'back' && capturedImages.front.fullFrame) {
576
1617
  this.completeProcess(true);
577
1618
  }
578
1619
  }
579
- async getCameraInfo() {
580
- return {
581
- availableCameras: this.availableCameras.map(camera => ({
582
- id: camera.deviceId,
583
- label: camera.label || 'Unknown Camera',
584
- selected: camera.deviceId === this.selectedCameraId
585
- })),
586
- selectedCameraId: this.selectedCameraId,
587
- deviceType: this.deviceType,
588
- isMultipleCamerasAvailable: this.isMultipleCamerasAvailable,
589
- preferredFacing: this.preferredCameraFacing,
590
- userPreferredCamera: this.preferredCamera
591
- };
592
- }
593
- async setPreferredCamera(camera) {
594
- this.preferredCamera = camera;
595
- this.debugLog('🎯 Camera preference changed to:', camera);
596
- // Re-detect and apply new camera preference
597
- await this.enumerateAndDetectCameras();
598
- // If video is active, switch to the new preferred camera
599
- if (this.isVideoActive && this.selectedCameraId) {
600
- await this.switchCamera(this.selectedCameraId);
601
- }
1620
+ async getStatus() {
1621
+ const captureState = this.stateManager.getCaptureState();
1622
+ const capturedImages = this.stateManager.getCapturedImages();
602
1623
  return {
603
- success: true,
604
- selectedCamera: this.selectedCameraId,
605
- availableCameras: this.availableCameras.length
1624
+ isVideoActive: captureState.isVideoActive,
1625
+ captureStep: captureState.step,
1626
+ hasImages: !!(capturedImages.front.fullFrame || capturedImages.back.fullFrame),
1627
+ isProcessCompleted: this.stateManager.isProcessCompleted(),
1628
+ isModelPreloaded: this.detectionService.isModelLoaded()
606
1629
  };
607
1630
  }
608
- async loadMobileNetModel() {
609
- try {
610
- this.debugLog('🤖 Loading MobileNet model...');
611
- // Load class map
612
- const classResponse = await fetch(this.MOBILENET_CLASSES_PATH);
613
- if (!classResponse.ok) {
614
- throw new Error(`Failed to load class map: ${this.MOBILENET_CLASSES_PATH}`);
615
- }
616
- this.mobileNetClassMap = await classResponse.json();
617
- this.debugLog('📋 MobileNet classes loaded:', this.mobileNetClassMap);
618
- // Load model
619
- const sessionOptions = this.getSessionOptions();
620
- this.mobileNetSession = await window.ort.InferenceSession.create(this.MOBILENET_MODEL_PATH, sessionOptions);
621
- this.debugLog('✅ MobileNet model loaded successfully');
622
- }
623
- catch (error) {
624
- this.debugLog('❌ Error loading MobileNet model:', error);
625
- throw error;
626
- }
627
- }
628
- preprocessMobileNet(canvas) {
629
- // Create a temporary canvas for MobileNet preprocessing (224x224)
630
- const tempCanvas = document.createElement('canvas');
631
- tempCanvas.width = 224;
632
- tempCanvas.height = 224;
633
- const tempCtx = tempCanvas.getContext('2d');
634
- // Draw the cropped image onto the 224x224 canvas
635
- tempCtx.drawImage(canvas, 0, 0, 224, 224);
636
- // Get image data and preprocess for MobileNet
637
- const imageData = tempCtx.getImageData(0, 0, 224, 224);
638
- const data = imageData.data;
639
- const hw = 224 * 224;
640
- const arr = new Float32Array(3 * hw);
641
- // Normalize pixels: (pixel/255 - 0.5) / 0.5 = (pixel - 127.5) / 127.5
642
- for (let i = 0; i < hw; i++) {
643
- arr[i] = (data[i * 4 + 0] / 255 - 0.5) / 0.5; // R
644
- arr[hw + i] = (data[i * 4 + 1] / 255 - 0.5) / 0.5; // G
645
- arr[2 * hw + i] = (data[i * 4 + 2] / 255 - 0.5) / 0.5; // B
646
- }
647
- return new window.ort.Tensor('float32', arr, [1, 3, 224, 224]);
648
- }
649
- async classifyDocument(canvas) {
650
- if (!this.mobileNetSession || !this.mobileNetClassMap) {
651
- this.debugLog('⚠️ MobileNet model not loaded');
652
- return null;
653
- }
654
- try {
655
- this.debugLog('🔍 Classifying document...');
656
- // Preprocess image for MobileNet
657
- const inputTensor = this.preprocessMobileNet(canvas);
658
- // Run inference
659
- const feeds = { input: inputTensor };
660
- const results = await this.mobileNetSession.run(feeds);
661
- const output = results[Object.keys(results)[0]].data;
662
- // Find the class with highest confidence
663
- const maxIdx = output.reduce((bestIdx, val, idx, arr) => val > arr[bestIdx] ? idx : bestIdx, 0);
664
- const confidence = output[maxIdx];
665
- const className = this.mobileNetClassMap[maxIdx.toString()] || "unknown";
666
- this.debugLog('📄 Document classification result:', {
667
- class: className,
668
- confidence: confidence.toFixed(3),
669
- classIndex: maxIdx
670
- });
671
- return {
672
- class: className,
673
- confidence: confidence,
674
- classIndex: maxIdx
675
- };
676
- }
677
- catch (error) {
678
- this.debugLog('❌ Error classifying document:', error);
679
- return null;
680
- }
681
- }
682
- cleanup() {
683
- if (this.animationId) {
684
- cancelAnimationFrame(this.animationId);
685
- }
686
- if (this.videoStream) {
687
- this.videoStream.getTracks().forEach(track => track.stop());
688
- }
689
- // Limpiar canvas en cleanup general
690
- if (this.canvasRef) {
691
- const ctx = this.canvasRef.getContext("2d");
692
- if (ctx) {
693
- ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
694
- }
695
- }
696
- // OPTIMIZATION: Clean up canvas pool
697
- this.cleanupCanvasPool();
698
- }
699
- cleanupCanvasPool() {
700
- // Release canvas references for garbage collection
701
- if (this.preprocessCanvas) {
702
- this.preprocessCtx = undefined;
703
- this.preprocessCanvas = undefined;
704
- }
705
- if (this.captureCanvas) {
706
- this.captureCtx = undefined;
707
- this.captureCanvas = undefined;
708
- }
709
- // Disposed ONNX sessions
710
- if (this.session) {
711
- this.session.release?.();
712
- this.session = undefined;
713
- }
714
- if (this.mobileNetSession) {
715
- this.mobileNetSession.release?.();
716
- this.mobileNetSession = undefined;
717
- }
718
- this.mobileNetClassMap = undefined;
719
- this.isModelPreloaded = false;
720
- this.debugLog('🧹 Canvas pool cleaned up');
721
- }
722
- async getMaxResolution() {
723
- try {
724
- // Build constraints with selected camera if available
725
- const videoConstraints = {};
726
- if (this.selectedCameraId) {
727
- videoConstraints.deviceId = { exact: this.selectedCameraId };
728
- }
729
- else if (this.preferredCameraFacing) {
730
- videoConstraints.facingMode = this.preferredCameraFacing;
731
- }
732
- else {
733
- videoConstraints.facingMode = "environment";
734
- }
735
- // Get temporary stream to access capabilities
736
- const tempStream = await navigator.mediaDevices.getUserMedia({
737
- video: videoConstraints
738
- });
739
- const videoTrack = tempStream.getVideoTracks()[0];
740
- const capabilities = videoTrack.getCapabilities();
741
- // Detener el stream temporal
742
- tempStream.getTracks().forEach(track => track.stop());
743
- // Build constraints with resolution optimized for tablets
744
- const constraints = {};
745
- // Set camera selection constraints
746
- if (this.selectedCameraId) {
747
- constraints.deviceId = { exact: this.selectedCameraId };
748
- }
749
- else if (this.preferredCameraFacing) {
750
- constraints.facingMode = this.preferredCameraFacing;
751
- }
752
- else {
753
- constraints.facingMode = "environment";
754
- }
755
- if (capabilities.width && capabilities.height) {
756
- // Limitar resolución máxima para tablets para evitar problemas de rendimiento
757
- const maxWidth = Math.min(capabilities.width.max, 1920);
758
- const maxHeight = Math.min(capabilities.height.max, 1080);
759
- // Para tablets, usar resolución moderada en lugar de máxima
760
- const isTablet = /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768;
761
- if (isTablet) {
762
- constraints.width = { ideal: Math.min(maxWidth, 1280) };
763
- constraints.height = { ideal: Math.min(maxHeight, 720) };
764
- }
765
- else {
766
- constraints.width = { ideal: maxWidth };
767
- constraints.height = { ideal: maxHeight };
768
- }
769
- this.debugLog('📐 Resolution capabilities:', {
770
- maxWidth: capabilities.width.max,
771
- maxHeight: capabilities.height.max,
772
- selectedWidth: constraints.width.ideal,
773
- selectedHeight: constraints.height.ideal,
774
- isTablet
775
- });
776
- }
777
- return constraints;
778
- }
779
- catch (err) {
780
- this.debugLog('⚠️ Could not get capabilities, using fallback');
781
- // Optimized fallback for tablets
782
- const isTablet = /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768;
783
- const fallbackConstraints = {
784
- width: { ideal: isTablet ? 1280 : 1920 },
785
- height: { ideal: isTablet ? 720 : 1080 }
786
- };
787
- // Add camera selection to fallback
788
- if (this.selectedCameraId) {
789
- fallbackConstraints.deviceId = { exact: this.selectedCameraId };
790
- }
791
- else if (this.preferredCameraFacing) {
792
- fallbackConstraints.facingMode = this.preferredCameraFacing;
793
- }
794
- else {
795
- fallbackConstraints.facingMode = "environment";
796
- }
797
- return fallbackConstraints;
1631
+ async preloadModel() {
1632
+ if (this.detectionService.isModelLoaded()) {
1633
+ this.logger.state('MODELO_YA_PRECARGADO');
1634
+ this.updateStatus('Modelos ya cargados', '', 'ready');
1635
+ return { success: true, message: 'Model already loaded' };
798
1636
  }
799
- }
800
- async setupCamera() {
801
1637
  try {
802
- const constraints = await this.getMaxResolution();
803
- const stream = await navigator.mediaDevices.getUserMedia({
804
- video: constraints,
805
- audio: false
806
- });
807
- if (this.videoRef) {
808
- this.videoRef.srcObject = stream;
809
- this.videoStream = stream;
810
- // Determine if video should be mirrored
811
- const isRear = this.isRearCamera(stream);
812
- this.shouldMirrorVideo = !isRear;
813
- this.debugLog('📹 Rear camera:', isRear);
814
- this.debugLog('🪞 Should mirror video:', this.shouldMirrorVideo);
815
- return new Promise((resolve) => {
816
- this.videoRef.onloadedmetadata = async () => {
817
- await this.videoRef.play();
818
- this.isVideoActive = true;
819
- // Update mask dimensions once video is loaded
820
- if (this.canvasRef) {
821
- const container = this.canvasRef.parentElement;
822
- const rect = container.getBoundingClientRect();
823
- this.updateMaskDimensions(rect);
824
- }
825
- resolve();
826
- };
827
- });
1638
+ const loadStartTime = performance.now();
1639
+ this.updateStatus('Descargando modelo de detección...', 'Obteniendo red neuronal para reconocimiento de documentos', 'loading');
1640
+ this.stateManager.updateCaptureState({ isLoading: true });
1641
+ await this.detectionService.loadModel();
1642
+ this.updateStatus('Cargando clasificador...', 'Preparando modelo de clasificación de tipos de documento', 'loading');
1643
+ await this.detectionService.loadClassificationModel();
1644
+ const loadEndTime = performance.now();
1645
+ const loadTime = loadEndTime - loadStartTime;
1646
+ // Record ONNX load time
1647
+ if (this.debug) {
1648
+ this.recordOnnxPerformance(loadTime, 0);
828
1649
  }
1650
+ this.updateStatus('Optimizando modelos...', 'Configurando parámetros de rendimiento', 'loading');
1651
+ await new Promise(resolve => setTimeout(resolve, 300));
1652
+ this.updateStatus('Modelos precargados', '', 'ready');
1653
+ this.stateManager.updateCaptureState({ isLoading: false });
1654
+ this.emitReadyEvent();
1655
+ this.logger.state('MODELOS_PRECARGADOS_EXITOSAMENTE', { loadTime: Math.round(loadTime) });
1656
+ return { success: true, message: 'Models preloaded successfully' };
829
1657
  }
830
- catch (err) {
831
- this.debugLog("❌ No se pudo acceder a la cámara:", err);
832
- this.handleCameraPermissionError(err);
1658
+ catch (error) {
1659
+ this.logger.error('Error al precargar modelos:', error);
1660
+ this.updateStatus('Error al cargar modelos', 'No se pudieron descargar los recursos necesarios', 'error');
1661
+ this.stateManager.updateCaptureState({ isLoading: false });
1662
+ return { success: false, error: error.message };
833
1663
  }
834
1664
  }
835
- preprocess(video) {
836
- // OPTIMIZATION: Reuse canvas instead of creating new ones
837
- if (!this.preprocessCanvas || !this.preprocessCtx) {
838
- this.initializeCanvasPool();
1665
+ async getCameraInfo() {
1666
+ return this.cameraService.getCameraInfo();
1667
+ }
1668
+ async setPreferredCamera(camera) {
1669
+ this.preferredCamera = camera;
1670
+ this.serviceContainer.updateConfig({ preferredCamera: camera });
1671
+ await this.cameraService.enumerateDevices();
1672
+ const captureState = this.stateManager.getCaptureState();
1673
+ if (captureState.isVideoActive) {
1674
+ const selectedCameraId = this.cameraService.getSelectedCameraId();
1675
+ if (selectedCameraId) {
1676
+ await this.cameraService.switchCamera(selectedCameraId);
1677
+ }
839
1678
  }
840
- // Clear and redraw on reused canvas
841
- this.preprocessCtx.clearRect(0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
842
- this.preprocessCtx.drawImage(video, 0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
843
- const imageData = this.preprocessCtx.getImageData(0, 0, this.INPUT_SIZE, this.INPUT_SIZE);
844
- const [R, G, B] = [[], [], []];
845
- const { data } = imageData;
846
- // Optimized pixel processing
847
- for (let i = 0; i < data.length; i += 4) {
848
- R.push(data[i] / 255);
849
- G.push(data[i + 1] / 255);
850
- B.push(data[i + 2] / 255);
1679
+ return {
1680
+ success: true,
1681
+ selectedCamera: this.cameraService.getSelectedCameraId(),
1682
+ availableCameras: this.cameraService.getAvailableCameras().length
1683
+ };
1684
+ }
1685
+ async setCaptureDelay(delay) {
1686
+ if (delay < 0 || delay > 10000) {
1687
+ throw new Error('Capture delay must be between 0 and 10000 milliseconds');
851
1688
  }
852
- const transposedData = new Float32Array(R.concat(G, B));
853
- return new window.ort.Tensor("float32", transposedData, [1, 3, this.INPUT_SIZE, this.INPUT_SIZE]);
1689
+ this.captureDelay = delay;
1690
+ this.serviceContainer.updateConfig({ captureDelay: delay });
1691
+ return {
1692
+ success: true,
1693
+ captureDelay: this.captureDelay
1694
+ };
854
1695
  }
855
- getSessionOptions() {
1696
+ async getCaptureDelay() {
1697
+ return this.captureDelay;
1698
+ }
1699
+ async setTorchEnabled(enabled) {
1700
+ const torchEnabled = await this.cameraService.setTorchEnabled(enabled, this.videoStream);
856
1701
  return {
857
- executionProviders: [
858
- // Try WebGL first for GPU acceleration, fallback to WASM
859
- 'webgl',
860
- 'wasm'
861
- ],
862
- graphOptimizationLevel: 'all', // Maximum optimization
863
- logSeverityLevel: this.debug ? 2 : 4, // Reduce logging overhead in production
864
- logVerbosityLevel: 0,
865
- enableCpuMemArena: true,
866
- enableMemPattern: true,
867
- executionMode: 'parallel',
868
- interOpNumThreads: 2,
869
- intraOpNumThreads: 2,
1702
+ success: torchEnabled,
1703
+ enabled: torchEnabled ? enabled : false
1704
+ };
1705
+ }
1706
+ async focusAtPoint(x, y) {
1707
+ const focused = await this.cameraService.focusAtPoint(x, y, this.videoStream);
1708
+ return {
1709
+ success: focused,
1710
+ coordinates: { x, y }
870
1711
  };
871
1712
  }
1713
+ // DETECTION METHODS
872
1714
  async startDetection() {
1715
+ this.logger.state('INICIANDO_DETECCION');
873
1716
  try {
874
- // Check if model is already preloaded
875
- if (!this.session) {
876
- this.isLoading = true;
877
- this.statusMessage = "Cargando modelos...";
878
- this.statusColor = "#007bff";
879
- const modelPath = this.MODEL_PATH;
880
- this.debugLog('🤖 Loading detection model:', modelPath);
881
- const sessionOptions = this.getSessionOptions();
882
- this.session = await window.ort.InferenceSession.create(modelPath, sessionOptions);
883
- // Load MobileNet model if classification is enabled and not already loaded
884
- if (this.useDocumentClassification && !this.mobileNetSession) {
885
- await this.loadMobileNetModel();
1717
+ // Paso 1: Verificar y cargar modelos si es necesario
1718
+ if (!this.detectionService.isModelLoaded()) {
1719
+ const loadStartTime = performance.now();
1720
+ this.updateStatus('Cargando modelo de detección...', 'Descargando red neuronal para reconocimiento', 'loading');
1721
+ this.stateManager.updateCaptureState({ isLoading: true });
1722
+ await this.detectionService.loadModel();
1723
+ this.updateStatus('Cargando clasificador...', 'Preparando modelo de clasificación de documentos', 'loading');
1724
+ await this.detectionService.loadClassificationModel();
1725
+ const loadEndTime = performance.now();
1726
+ const loadTime = loadEndTime - loadStartTime;
1727
+ // Record ONNX load time
1728
+ if (this.debug) {
1729
+ this.recordOnnxPerformance(loadTime, 0);
886
1730
  }
887
- this.isModelPreloaded = true;
888
- this.emitReadyEvent();
1731
+ this.logger.state('MODELOS_CARGADOS_EN_DETECCION', { loadTime: Math.round(loadTime) });
889
1732
  }
890
- else {
891
- this.debugLog('🚀 Using preloaded models');
892
- this.statusMessage = "Usando modelos precargados...";
893
- this.statusColor = "#007bff";
1733
+ // Paso 2: Detectar y configurar dispositivos
1734
+ this.updateStatus('Detectando cámaras...', 'Buscando dispositivos de captura disponibles', 'loading');
1735
+ await this.cameraService.enumerateDevices();
1736
+ // Paso 3: Configurar cámara seleccionada
1737
+ this.updateStatus('Configurando cámara...', 'Estableciendo resolución y parámetros óptimos', 'loading');
1738
+ const stream = await this.cameraService.setupCamera();
1739
+ // Paso 4: Inicializar video y ocultar estado inmediatamente
1740
+ this.updateStatus('Captura activa', 'Buscando documento en el marco de captura', 'active');
1741
+ await this.initializeVideoStream(stream);
1742
+ // Paso 5: Calibrar máscara
1743
+ if (this.detectionContainer) {
1744
+ const container = this.detectionContainer.parentElement;
1745
+ const rect = container.getBoundingClientRect();
1746
+ this.updateMaskDimensions(rect);
894
1747
  }
895
- this.statusMessage = "Configurando cámara...";
896
- await this.setupCamera();
1748
+ // Finalizar e iniciar captura
897
1749
  this.startTime = Date.now();
898
- this.isLoading = false;
1750
+ this.stateManager.updateCaptureState({ isLoading: false });
899
1751
  this.detectFrame();
900
1752
  }
901
1753
  catch (err) {
902
- this.debugLog("Error al inicializar:", err);
903
- this.statusMessage = "Error al inicializar el detector";
904
- this.statusColor = "#ff6b6b";
905
- this.isLoading = false;
1754
+ this.logger.error('Error al inicializar detección:', err);
1755
+ this.updateStatus('Error al iniciar captura', 'No se pudo completar la inicialización', 'error');
1756
+ this.stateManager.updateCaptureState({ isLoading: false });
1757
+ }
1758
+ }
1759
+ async initializeVideoStream(stream) {
1760
+ if (this.videoRef) {
1761
+ this.videoRef.srcObject = stream;
1762
+ this.videoStream = stream;
1763
+ const isRear = this.cameraService.isRearCamera(stream);
1764
+ this.shouldMirrorVideo = !isRear;
1765
+ this.logger.state('CAMARA_CONFIGURADA', {
1766
+ isRearCamera: isRear,
1767
+ shouldMirrorVideo: this.shouldMirrorVideo
1768
+ });
1769
+ return new Promise((resolve) => {
1770
+ this.videoRef.onloadedmetadata = async () => {
1771
+ await this.videoRef.play();
1772
+ this.stateManager.updateCaptureState({ isVideoActive: true });
1773
+ resolve();
1774
+ };
1775
+ });
1776
+ }
1777
+ else {
1778
+ throw new Error('Video element not available');
906
1779
  }
907
1780
  }
908
1781
  async detectFrame() {
909
1782
  try {
910
- if (!this.videoRef || !this.canvasRef || !this.session)
1783
+ const frameStartTime = performance.now();
1784
+ const captureState = this.stateManager.getCaptureState();
1785
+ if (!this.videoRef || !this.detectionContainer || !this.detectionService.isModelLoaded())
911
1786
  return;
912
- // Pausar detección si está marcado como pausado
913
- if (this.isDetectionPaused) {
914
- // Solo continuar el bucle sin procesar detección
915
- if (this.captureStep !== 'completed') {
1787
+ if (captureState.isDetectionPaused) {
1788
+ if (captureState.step !== 'completed') {
916
1789
  this.animationId = requestAnimationFrame(() => this.detectFrame());
917
1790
  }
918
1791
  return;
919
1792
  }
920
- // OPTIMIZATION 1: Frame skipping for performance
1793
+ // Frame skipping for performance
921
1794
  this.frameSkipCounter++;
922
1795
  if (this.frameSkipCounter <= this.FRAME_SKIP) {
923
- if (this.captureStep !== 'completed') {
1796
+ if (captureState.step !== 'completed') {
924
1797
  this.animationId = requestAnimationFrame(() => this.detectFrame());
925
1798
  }
926
1799
  return;
927
1800
  }
928
1801
  this.frameSkipCounter = 0;
929
- // OPTIMIZATION 2: Throttle inference based on time
1802
+ // Throttle inference
930
1803
  const currentTime = Date.now();
931
1804
  if (currentTime - this.lastInferenceTime < this.MIN_INFERENCE_INTERVAL) {
932
- if (this.captureStep !== 'completed') {
1805
+ if (captureState.step !== 'completed') {
933
1806
  this.animationId = requestAnimationFrame(() => this.detectFrame());
934
1807
  }
935
1808
  return;
936
1809
  }
937
1810
  this.lastInferenceTime = currentTime;
938
- const ctx = this.canvasRef.getContext("2d");
939
- const inputTensor = this.preprocess(this.videoRef);
940
- const feeds = { [this.session.inputNames[0]]: inputTensor };
941
- const results = await this.session.run(feeds);
942
- const output = results[this.session.outputNames[0]].data;
943
- const finalBoxes = [];
944
- for (let i = 0; i < output.length; i += 6) {
945
- const [x1, y1, x2, y2, score, classId] = output.slice(i, i + 6);
946
- if (score > this.CONFIDENCE_THRESHOLD) {
947
- finalBoxes.push({ x: x1, y: y1, w: x2 - x1, h: y2 - y1, score, classId });
948
- if (this.startTime && Date.now() - this.startTime < 5000) {
949
- const currentBox = { x: x1, y: y1, w: x2 - x1, h: y2 - y1, score };
950
- const isWellPositioned = this.isCardInFrame(currentBox);
951
- const effectiveScore = isWellPositioned ? score * 1.2 : score;
952
- if (effectiveScore > this.bestScore) {
953
- this.bestScore = effectiveScore;
954
- }
1811
+ // Measure preprocessing and inference time
1812
+ const inferenceStartTime = performance.now();
1813
+ const inputTensor = this.detectionService.preprocess(this.videoRef);
1814
+ // Standard detection without quality validation
1815
+ const detections = await this.detectionService.runInference(inputTensor);
1816
+ const inferenceTime = performance.now() - inferenceStartTime;
1817
+ // Record ONNX performance metrics
1818
+ if (this.debug) {
1819
+ this.recordOnnxPerformance(0, inferenceTime);
1820
+ }
1821
+ // Update best score logic
1822
+ if (this.startTime && Date.now() - this.startTime < 5000) {
1823
+ detections.forEach((detection) => {
1824
+ const isWellPositioned = this.detectionService.isCardInFrame(detection);
1825
+ const effectiveScore = isWellPositioned ? detection.score * 1.2 : detection.score;
1826
+ if (effectiveScore > captureState.bestScore) {
1827
+ this.stateManager.updateCaptureState({ bestScore: effectiveScore });
955
1828
  }
956
- }
1829
+ });
1830
+ }
1831
+ // Update document detection state
1832
+ const wasDocumentDetected = this.hasDocumentDetected;
1833
+ this.hasDocumentDetected = detections.length > 0;
1834
+ // Restart timer if document detected during back capture
1835
+ if (!wasDocumentDetected && this.hasDocumentDetected &&
1836
+ captureState.step === 'back' && this.enableBackDocumentTimer) {
1837
+ this.startBackDocumentTimer();
957
1838
  }
958
- // OPTIMIZATION 3: Adaptive frame rate based on detection success
959
- if (finalBoxes.length === 0) {
1839
+ // Adaptive frame rate
1840
+ if (detections.length === 0) {
960
1841
  this.consecutiveFailures++;
961
1842
  }
962
1843
  else {
963
1844
  this.consecutiveFailures = 0;
964
1845
  }
965
- ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
1846
+ // Update detection boxes for debug
1847
+ if (this.debug) {
1848
+ this.updateDetectionBoxes(detections);
1849
+ }
1850
+ else {
1851
+ this.detectionBoxes = [];
1852
+ }
1853
+ this.updateMaskColor(detections);
1854
+ // Record frame processing metrics
966
1855
  if (this.debug) {
967
- this.drawDetections(ctx, finalBoxes);
1856
+ const frameEndTime = performance.now();
1857
+ const totalFrameTime = frameEndTime - frameStartTime;
1858
+ this.recordFrameProcessing(totalFrameTime, detections.length);
968
1859
  }
969
- this.updateGuidanceStatus(finalBoxes);
970
- this.updateMaskColor(finalBoxes);
971
- // Solo continuar si no hemos completado el proceso
972
- if (this.captureStep !== 'completed') {
973
- // OPTIMIZATION 4: Reduce frame rate when no detection for better battery life
1860
+ // Continue detection loop
1861
+ if (captureState.step !== 'completed') {
974
1862
  if (this.consecutiveFailures > this.MAX_FAILURES) {
975
- // Reduce to ~5fps when no detection for 0.5 seconds
976
1863
  setTimeout(() => this.detectFrame(), 200);
977
1864
  }
978
1865
  else {
@@ -981,102 +1868,99 @@ const JaakStamps = class {
981
1868
  }
982
1869
  }
983
1870
  catch (e) {
984
- this.debugLog("Error en inferencia:", e);
985
- // Solo continuar si no hemos completado el proceso
986
- if (this.captureStep !== 'completed') {
987
- // On error, wait longer before retrying
1871
+ this.logger.error('Error en inferencia de modelo:', e);
1872
+ const captureState = this.stateManager.getCaptureState();
1873
+ if (captureState.step !== 'completed') {
988
1874
  setTimeout(() => this.detectFrame(), 100);
989
1875
  }
990
1876
  }
991
1877
  }
992
- isCardInFrame(box) {
993
- const cardCenterX = box.x + box.w / 2;
994
- const cardCenterY = box.y + box.h / 2;
995
- const frameCenterX = this.INPUT_SIZE / 2;
996
- const frameCenterY = this.INPUT_SIZE / 2;
997
- const toleranceX = 40;
998
- const toleranceY = 30;
999
- const isCentered = Math.abs(cardCenterX - frameCenterX) < toleranceX &&
1000
- Math.abs(cardCenterY - frameCenterY) < toleranceY;
1001
- const isGoodSize = box.w > 150 && box.w < 300 && box.h > 90 && box.h < 200;
1002
- return isCentered && isGoodSize;
1878
+ // ... (continuing with remaining methods)
1879
+ // Due to length constraints, I'll provide the key architectural changes
1880
+ disconnectedCallback() {
1881
+ this.cleanup();
1003
1882
  }
1004
- checkSideAlignment(box) {
1005
- if (!this.videoRef)
1006
- return { top: false, right: false, bottom: false, left: false };
1007
- // Get video dimensions to calculate actual mask boundaries in model space
1883
+ cleanup() {
1884
+ if (this.animationId) {
1885
+ cancelAnimationFrame(this.animationId);
1886
+ }
1887
+ if (this.videoStream) {
1888
+ this.videoStream.getTracks().forEach(track => track.stop());
1889
+ }
1890
+ if (this.alignmentTimer) {
1891
+ clearTimeout(this.alignmentTimer);
1892
+ this.alignmentTimer = undefined;
1893
+ }
1894
+ if (this.performanceUpdateInterval) {
1895
+ clearInterval(this.performanceUpdateInterval);
1896
+ this.performanceUpdateInterval = undefined;
1897
+ }
1898
+ this.detectionBoxes = [];
1899
+ this.alignmentStartTime = undefined;
1900
+ this.hasDocumentDetected = false;
1901
+ this.serviceContainer?.cleanup();
1902
+ }
1903
+ render() {
1904
+ const captureState = this.stateManager?.getCaptureState() || {
1905
+ isVideoActive: false,
1906
+ showFlipAnimation: false,
1907
+ showSuccessAnimation: false,
1908
+ step: 'front',
1909
+ isCapturing: false
1910
+ };
1911
+ const cameraInfo = this.cameraService?.getCameraInfo() || {
1912
+ availableCameras: [],
1913
+ selectedCameraId: null,
1914
+ deviceType: 'desktop'};
1915
+ return (index.h("div", { key: '9d4d77042218ab3d0bc4fb1ce879db09dc3e78b4', class: "detector-container" }, index.h("div", { key: '51ac8ccb1275637282b038ad8364d19f14ef61b1', class: "video-container" }, index.h("video", { key: 'a34f0481da20ec5b7b62b21aa275ec5f6dfec5d1', ref: el => this.videoRef = el, autoplay: true, muted: true, playsinline: true, class: this.shouldMirrorVideo ? 'mirror' : '', style: { display: captureState.isVideoActive ? 'block' : 'none' } }), index.h("div", { key: '43b71dfe3eaf20ffa66a4139e24b1af6a2074631', ref: el => this.detectionContainer = el, class: `detection-overlay ${this.shouldMirrorVideo ? 'mirror' : ''}` }, this.debug && this.detectionBoxes.map((box, index$1) => (index.h("div", { key: index$1, class: "detection-box", style: {
1916
+ position: 'absolute',
1917
+ left: `${box.x}px`,
1918
+ top: `${box.y}px`,
1919
+ width: `${box.w}px`,
1920
+ height: `${box.h}px`,
1921
+ border: '2px solid #32406C',
1922
+ pointerEvents: 'none',
1923
+ boxSizing: 'border-box'
1924
+ } })))), this.isMaskReady && (index.h("div", { key: 'c2596f8cabb227e3ea3b082495f52b141cb1690f', class: "overlay-mask" }, index.h("div", { key: 'ba1ea39b5df1cd312a9bd9410b7225a29cfb8a29', class: "card-outline" }, index.h("div", { key: '6ce4d4d969d5f3fbcf75a4eb285f61d434c5633b', class: "side side-top" }), index.h("div", { key: '36653ecc4fcda25dfc84bc4d1f4c4c5dfb71ced7', class: "side side-right" }), index.h("div", { key: '8b108fa8fb370b229a0d1a77cb1ff044319a8f75', class: "side side-bottom" }), index.h("div", { key: 'a7aa3b45c065a50445aafd5c3ccefb57b0c9c769', class: "side side-left" }), !captureState.showFlipAnimation && !captureState.showSuccessAnimation && (index.h("div", { key: 'b94b92273e12a019238e11ae57f21e140f13c3f1', class: "guide-text" }, "Alinee su identificaci\u00F3n con el marco"))), captureState.step === 'back' && !captureState.showFlipAnimation && !captureState.showSuccessAnimation && (index.h("div", { key: '144c3741003ec0cc148bb6ce92012d7b3cd110c8', class: "skip-section" }, index.h("div", { key: 'f0a984a6b75afa374c5d47da9d53e39f98071da0', class: "skip-explanation" }, "Si tu documento no tiene lado trasero, da clic en el bot\u00F3n para continuar con el proceso"), index.h("button", { key: '57ac30d776e30709aab6e0a621cef889da3cc677', class: "skip-button", onClick: () => this.skipBackCapture(), type: "button" }, this.enableBackDocumentTimer && this.backDocumentTimerRemaining > 0
1925
+ ? `Saltar reverso (${this.backDocumentTimerRemaining}s)`
1926
+ : 'Saltar reverso'))), captureState.isVideoActive && (index.h("div", { key: 'bd45628707e0169317ed3dd4247e1056d7046104', class: "camera-controls" }, index.h("button", { key: 'e750fbf4d5d76d9e9596823bb2b86801ef19e485', class: `camera-selector-button ${this.isSwitchingCamera ? 'loading' : ''}`, onClick: () => this.toggleCameraSelector(), type: "button", title: "Seleccionar c\u00E1mara", disabled: this.isSwitchingCamera }, this.isSwitchingCamera ? (index.h("div", { class: "button-spinner" })) : ('Cámaras')))), this.showCameraSelector && cameraInfo.availableCameras.length > 0 && (index.h("div", { key: '40ada780a8ea13aeeaee9cb39bc6496f69f2679d', class: "camera-selector-dropdown" }, index.h("div", { key: '3a920bb02ba024f0359513f10544bf0528ecee6d', class: "camera-selector-header" }, index.h("span", { key: 'e5efd478a602b13821ba53cb9a7a59d1b4a71178' }, "Seleccionar C\u00E1mara"), index.h("button", { key: '2df6cc11e162be74862029e33815927e3fa8f2f8', class: "close-selector", onClick: () => this.toggleCameraSelector(), type: "button" }, "\u00D7")), index.h("div", { key: '06934642dff134c9e1a6872f935a5892a9c7b835', class: "camera-list" }, cameraInfo.availableCameras.map((camera) => (index.h("button", { key: camera.id, class: `camera-option ${cameraInfo.selectedCameraId === camera.id ? 'selected' : ''}`, onClick: () => this.handleCameraSwitch(camera.id), type: "button" }, index.h("span", { class: "camera-label" }, camera.label || `Cámara ${cameraInfo.availableCameras.indexOf(camera) + 1}`), cameraInfo.selectedCameraId === camera.id && (index.h("span", { class: "selected-indicator" }, "\u2713")))))), index.h("div", { key: '8d5ede287c2efb0ebe418bee8dc7fb310443c991', class: "device-info" }, index.h("small", { key: 'b7dd10565113694b09f01ab28ffa29262ae1d2bc' }, "Dispositivo: ", cameraInfo.deviceType)))))), captureState.isCapturing && (index.h("div", { key: '2f9e549e6c0c56d4db5835e58361fb17bba6cf90', class: "capture-animation" })), captureState.showFlipAnimation && (index.h("div", { key: 'a6ef0ed127f23e9d764896ed743ca8d8454851a6', class: "flip-animation" }, index.h("div", { key: '0b04411f88a634bb3243b00ca2d5a45fd6962dea', class: "id-card-icon" }), index.h("div", { key: '9327be58879b407cea87f455989406e58302e9d1', class: "flip-text" }, "\u00A1Voltea tu identificaci\u00F3n!"))), captureState.showSuccessAnimation && (index.h("div", { key: 'bd268578b5003e99256e3340b4a9a6fb7656be64', class: "success-animation" }, index.h("div", { key: '064e0a5b9851de14bc6829f028e7ea7161fd1633', class: "check-icon" }), index.h("div", { key: 'bdcd94a8806399e396e218a359c10b11d05f38d6', class: "success-text" }, "\u00A1Proceso completado!"))), index.h("div", { key: '3d9af617376c16e44e065873919c3d90ff46110a', class: `component-status status-${this.currentStatus.type}` }, (this.currentStatus.type === 'loading' || this.currentStatus.type === 'initializing') && (index.h("div", { key: 'be7b56decfd6f12dcca46b2a9456480e3d5ac00e', class: "status-spinner" })), index.h("div", { key: '5fe9850883cb749cebaa18c3e9c13ca21325e373', class: "status-content" }, index.h("div", { key: '43fdb50691e4b98e56126af1a129f625061128bf', class: "status-message" }, this.currentStatus.message), this.currentStatus.description && (index.h("div", { key: 'b76cbfa4a0349421fcd535d3fd8bf40c89ca3aef', class: "status-description" }, this.currentStatus.description)))), this.debug && (index.h("div", { key: 'eddefb464af0487ab9ab2a0f76dccbb6a2092e51', class: "performance-monitor" }, index.h("div", { key: '7b7d93b56cb4cd4fbb1bdde89c6150b282fa805e', class: "performance-expanded" }, index.h("div", { key: 'f302e8b9d985fb8cad53c4c4e0d37a3bf5a63261', class: "metrics-row" }, index.h("div", { key: 'e8d8f2420ee8643a7f454d38065c75b5be598f77', class: "metric-compact" }, index.h("span", { key: '6e52eb5bb06e0df57e61774de38c31349f45e22b', class: "metric-label" }, "FPS"), index.h("span", { key: '6636f9cea20c49b49c17db86188e2f6b214c45c3', class: `metric-value ${this.performanceData.fps < 15 ? 'warning' : this.performanceData.fps < 10 ? 'danger' : 'good'}` }, this.performanceData.fps)), index.h("div", { key: 'afa1d9dec05b14256a1b1fcec8638b8c21a56e37', class: "metric-compact" }, index.h("span", { key: '9c55e30f5d01582648bacc1fb38ca1990485a591', class: "metric-label" }, "MEM"), index.h("span", { key: 'ca263601e8715027f95ffb2d5413a643dc570cf2', class: `metric-value ${this.performanceData.memoryUsage > 100 ? 'warning' : this.performanceData.memoryUsage > 200 ? 'danger' : 'good'}` }, this.performanceData.memoryUsage, "MB"))), index.h("div", { key: '03caf19961fd01b7781484d49d8121b1e78c161f', class: "metrics-row" }, index.h("div", { key: 'bffb08f01f703411f7c8605e4f8d95b1c37b5c0f', class: "metric-compact" }, index.h("span", { key: '9b92279b3253cc669412733facb3751f4344d69f', class: "metric-label" }, "INF"), index.h("span", { key: '3234d4150e2e1479e3b552767af8123e69911d6c', class: `metric-value ${this.performanceData.inferenceTime > 100 ? 'warning' : this.performanceData.inferenceTime > 200 ? 'danger' : 'good'}` }, this.performanceData.inferenceTime, "ms")), index.h("div", { key: '77082d2669db53f19fb50b3bbfa1ebefb0b86ff4', class: "metric-compact" }, index.h("span", { key: 'cc6ef96c72c800ba36dab2a388f1de863b2dacd5', class: "metric-label" }, "FRAME"), index.h("span", { key: 'e2bb4b7351869a76e810f8bbf20d805154495e35', class: `metric-value ${this.performanceData.frameProcessingTime > 50 ? 'warning' : this.performanceData.frameProcessingTime > 100 ? 'danger' : 'good'}` }, this.performanceData.frameProcessingTime, "ms"))), index.h("div", { key: '5700d7ef839fa77686299d79445203090dc8079f', class: "metrics-row" }, index.h("div", { key: 'a2999a035fccb7f250c01b084075490678513c87', class: "metric-compact" }, index.h("span", { key: '3677d58665996fa5969507dbaff9e956b2ea54ee', class: "metric-label" }, "DET"), index.h("span", { key: 'c584312438b339c82a3bdc33f00fb985ff2a6f55', class: "metric-value good" }, this.performanceData.successfulDetections, "/", this.performanceData.totalDetections)), index.h("div", { key: '58866219f213bf8607083f30be2d5ca5d0515cea', class: "metric-compact" }, index.h("span", { key: 'd90b450051331347f219b6c0f197536d0dcee9b7', class: "metric-label" }, "RATE"), index.h("span", { key: 'f6e6cd7948e06014e5a0416e4e81394fa36ec61d', class: `metric-value ${this.performanceData.detectionRate < 30 ? 'danger' : this.performanceData.detectionRate < 60 ? 'warning' : 'good'}` }, this.performanceData.detectionRate, "%")))))), index.h("div", { key: 'c1d0194f181a0082cc1aa5452de382908da61b65', class: "watermark" }, index.h("img", { key: 'e04d35f3c79a2b4a3922ab5b0f102220171fa7c0', src: "https://storage.googleapis.com/jaak-static/commons/powered-by-jaak.png", alt: "Powered by Jaak" })))));
1927
+ }
1928
+ // Utility methods
1929
+ updateDetectionBoxes(boxes) {
1930
+ if (!this.videoRef || !this.detectionContainer)
1931
+ return;
1008
1932
  const videoWidth = this.videoRef.videoWidth;
1009
1933
  const videoHeight = this.videoRef.videoHeight;
1010
- if (videoWidth === 0 || videoHeight === 0) {
1011
- return { top: false, right: false, bottom: false, left: false };
1012
- }
1013
- // Calculate video aspect ratio
1934
+ const container = this.detectionContainer.parentElement;
1935
+ const containerRect = container.getBoundingClientRect();
1936
+ const containerWidth = containerRect.width;
1937
+ const containerHeight = containerRect.height;
1938
+ if (videoWidth === 0 || videoHeight === 0)
1939
+ return;
1014
1940
  const videoAspectRatio = videoWidth / videoHeight;
1015
- // The model sees video stretched to 320x320, but we need to calculate where
1016
- // the mask should be in this distorted space to match the visual mask
1017
- // In the visual display, we calculate mask size based on the limiting dimension
1018
- // We need to replicate this logic but account for the distortion in model space
1019
- // Calculate the "display" dimensions (what the visual mask calculations use)
1020
- const containerAspectRatio = 1; // Assume square container for simplicity
1941
+ const containerAspectRatio = containerWidth / containerHeight;
1021
1942
  let displayedVideoWidth, displayedVideoHeight;
1943
+ let videoOffsetX = 0, videoOffsetY = 0;
1022
1944
  if (videoAspectRatio > containerAspectRatio) {
1023
- // Video is wider - letterboxed in display
1024
- displayedVideoWidth = this.INPUT_SIZE;
1025
- displayedVideoHeight = this.INPUT_SIZE / videoAspectRatio;
1026
- }
1027
- else {
1028
- // Video is taller - pillarboxed in display
1029
- displayedVideoHeight = this.INPUT_SIZE;
1030
- displayedVideoWidth = this.INPUT_SIZE * videoAspectRatio;
1031
- }
1032
- // Calculate mask dimensions using the same logic as updateMaskDimensions
1033
- const maxWidthByHeight = displayedVideoHeight * this.ID1_ASPECT_RATIO;
1034
- let visualMaskWidth, visualMaskHeight;
1035
- const sizeRatio = this.maskSize / 100;
1036
- if (maxWidthByHeight <= displayedVideoWidth) {
1037
- // Video height is the limiting factor
1038
- visualMaskHeight = displayedVideoHeight * sizeRatio;
1039
- visualMaskWidth = visualMaskHeight * this.ID1_ASPECT_RATIO;
1945
+ displayedVideoWidth = containerWidth;
1946
+ displayedVideoHeight = containerWidth / videoAspectRatio;
1947
+ videoOffsetY = (containerHeight - displayedVideoHeight) / 2;
1040
1948
  }
1041
1949
  else {
1042
- // Video width is the limiting factor
1043
- visualMaskWidth = displayedVideoWidth * sizeRatio;
1044
- visualMaskHeight = visualMaskWidth / this.ID1_ASPECT_RATIO;
1045
- }
1046
- // Now map these visual mask dimensions to the distorted model space
1047
- // The model stretches video to 320x320, so we need to apply the inverse transformation
1048
- const modelMaskWidth = visualMaskWidth * (this.INPUT_SIZE / displayedVideoWidth);
1049
- const modelMaskHeight = visualMaskHeight * (this.INPUT_SIZE / displayedVideoHeight);
1050
- // Calculate mask boundaries in model coordinate system (always centered in 320x320)
1051
- const maskCenterX = this.INPUT_SIZE / 2;
1052
- const maskCenterY = this.INPUT_SIZE / 2;
1053
- const maskLeft = maskCenterX - (modelMaskWidth / 2);
1054
- const maskRight = maskCenterX + (modelMaskWidth / 2);
1055
- const maskTop = maskCenterY - (modelMaskHeight / 2);
1056
- const maskBottom = maskCenterY + (modelMaskHeight / 2);
1057
- // Obtener las coordenadas del documento detectado
1058
- let docLeft = box.x;
1059
- let docRight = box.x + box.w;
1060
- const docTop = box.y;
1061
- const docBottom = box.y + box.h;
1062
- // Si el video está en modo mirror, invertir las coordenadas horizontales
1063
- if (this.shouldMirrorVideo) {
1064
- const originalDocLeft = docLeft;
1065
- const originalDocRight = docRight;
1066
- docLeft = this.INPUT_SIZE - originalDocRight;
1067
- docRight = this.INPUT_SIZE - originalDocLeft;
1950
+ displayedVideoHeight = containerHeight;
1951
+ displayedVideoWidth = containerHeight * videoAspectRatio;
1952
+ videoOffsetX = (containerWidth - displayedVideoWidth) / 2;
1068
1953
  }
1069
- // Tolerancia para considerar que un lado está alineado (en píxeles)
1070
- const tolerance = this.alignmentTolerance;
1071
- return {
1072
- top: Math.abs(docTop - maskTop) <= tolerance,
1073
- right: Math.abs(docRight - maskRight) <= tolerance,
1074
- bottom: Math.abs(docBottom - maskBottom) <= tolerance,
1075
- left: Math.abs(docLeft - maskLeft) <= tolerance
1076
- };
1077
- }
1078
- areAllSidesAligned(alignment) {
1079
- return alignment.top && alignment.right && alignment.bottom && alignment.left;
1954
+ const INPUT_SIZE = 320;
1955
+ const scaleX = displayedVideoWidth / INPUT_SIZE;
1956
+ const scaleY = displayedVideoHeight / INPUT_SIZE;
1957
+ this.detectionBoxes = boxes.map(det => ({
1958
+ x: det.x * scaleX + videoOffsetX,
1959
+ y: det.y * scaleY + videoOffsetY,
1960
+ w: det.w * scaleX,
1961
+ h: det.h * scaleY,
1962
+ score: det.score
1963
+ }));
1080
1964
  }
1081
1965
  updateMaskColor(boxes) {
1082
1966
  const cardOutline = this.el.shadowRoot?.querySelector('.card-outline');
@@ -1089,310 +1973,360 @@ const JaakStamps = class {
1089
1973
  let currentAlignment = { top: false, right: false, bottom: false, left: false };
1090
1974
  if (boxes.length > 0) {
1091
1975
  bestBox = boxes.reduce((best, current) => current.score > best.score ? current : best);
1092
- currentAlignment = this.checkSideAlignment(bestBox);
1976
+ const maskConfig = {
1977
+ INPUT_SIZE: 320,
1978
+ ID1_ASPECT_RATIO: 85.60 / 53.98,
1979
+ shouldMirrorVideo: this.shouldMirrorVideo,
1980
+ alignmentTolerance: this.alignmentTolerance,
1981
+ maskSize: this.maskSize,
1982
+ videoRef: this.videoRef
1983
+ };
1984
+ currentAlignment = this.detectionService.checkSideAlignment(bestBox, maskConfig);
1093
1985
  this.sideAlignment = currentAlignment;
1094
1986
  }
1095
1987
  else {
1096
- // Reset alignment when no detection
1097
1988
  this.sideAlignment = { top: false, right: false, bottom: false, left: false };
1098
1989
  }
1099
- // Actualizar colores de cada lado individualmente
1100
1990
  topSide?.classList.toggle('aligned', currentAlignment.top);
1101
1991
  rightSide?.classList.toggle('aligned', currentAlignment.right);
1102
1992
  bottomSide?.classList.toggle('aligned', currentAlignment.bottom);
1103
1993
  leftSide?.classList.toggle('aligned', currentAlignment.left);
1104
- // Verificar si todos los lados están alineados
1105
- const allSidesAligned = this.areAllSidesAligned(currentAlignment);
1994
+ const allSidesAligned = this.detectionService.areAllSidesAligned(currentAlignment);
1995
+ // Restart back document timer if any border is aligned during back capture
1996
+ const captureState = this.stateManager.getCaptureState();
1997
+ if (captureState.step === 'back' && this.enableBackDocumentTimer && bestBox) {
1998
+ const anyBorderAligned = currentAlignment.top || currentAlignment.right ||
1999
+ currentAlignment.bottom || currentAlignment.left;
2000
+ if (anyBorderAligned) {
2001
+ this.startBackDocumentTimer();
2002
+ }
2003
+ }
1106
2004
  if (allSidesAligned && bestBox) {
1107
2005
  cardOutline?.classList.add('perfect-match');
1108
2006
  corners?.forEach(corner => corner.classList.add('perfect-match'));
2007
+ // Debug logging
2008
+ this.logger.state('CAPTURE_EVALUATION', {
2009
+ allSidesAligned: true,
2010
+ hasScreenshotTaken: this.hasScreenshotTaken,
2011
+ captureDelay: this.captureDelay,
2012
+ alignmentStartTime: this.alignmentStartTime
2013
+ });
1109
2014
  if (!this.hasScreenshotTaken) {
1110
- this.lastDetectedBox = bestBox;
1111
- this.takeScreenshot().catch(error => {
1112
- this.debugLog('❌ Error taking screenshot:', error);
2015
+ const currentTime = Date.now();
2016
+ // Initialize alignment start time if not set
2017
+ if (!this.alignmentStartTime) {
2018
+ this.alignmentStartTime = currentTime;
2019
+ this.logger.state('ALIGNMENT_TIMER_STARTED', { startTime: currentTime });
2020
+ }
2021
+ // Check if document has been aligned for the configured delay
2022
+ const alignmentDuration = currentTime - this.alignmentStartTime;
2023
+ this.logger.state('ALIGNMENT_DURATION_CHECK', {
2024
+ alignmentDuration,
2025
+ captureDelay: this.captureDelay,
2026
+ readyToCapture: alignmentDuration >= this.captureDelay
1113
2027
  });
1114
- this.hasScreenshotTaken = true;
1115
- // Reset para permitir segunda captura
1116
- setTimeout(() => {
1117
- this.hasScreenshotTaken = false;
1118
- }, 2000);
2028
+ if (alignmentDuration >= this.captureDelay) {
2029
+ this.logger.state('TRIGGERING_CAPTURE', {
2030
+ alignmentDuration,
2031
+ captureDelay: this.captureDelay
2032
+ });
2033
+ this.lastDetectedBox = bestBox;
2034
+ this.takeScreenshot().catch(error => {
2035
+ this.logger.error('Error al tomar captura de pantalla:', error);
2036
+ });
2037
+ this.hasScreenshotTaken = true;
2038
+ this.alignmentStartTime = undefined;
2039
+ setTimeout(() => {
2040
+ this.hasScreenshotTaken = false;
2041
+ }, 2000);
2042
+ }
1119
2043
  }
1120
2044
  }
1121
2045
  else {
1122
2046
  cardOutline?.classList.remove('perfect-match');
1123
2047
  corners?.forEach(corner => corner.classList.remove('perfect-match'));
1124
- }
1125
- }
1126
- updateGuidanceStatus(boxes) {
1127
- if (boxes.length === 0) {
1128
- this.statusMessage = "Posicione la identificación dentro del marco";
1129
- this.statusColor = "#ff6b6b";
1130
- }
1131
- else {
1132
- const bestBox = boxes.reduce((best, current) => current.score > best.score ? current : best);
1133
- const alignment = this.checkSideAlignment(bestBox);
1134
- const alignedSides = [alignment.top, alignment.right, alignment.bottom, alignment.left].filter(Boolean).length;
1135
- const allSidesAligned = this.areAllSidesAligned(alignment);
1136
- if (allSidesAligned) {
1137
- this.statusMessage = "Identificación perfectamente alineada. Mantenga inmóvil";
1138
- this.statusColor = "#00ff00";
1139
- }
1140
- else if (alignedSides > 0) {
1141
- this.statusMessage = `Alinee los lados restantes (${alignedSides}/4 lados correctos)`;
1142
- this.statusColor = "#ffb366";
2048
+ // Reset alignment timer when document moves out of position
2049
+ if (this.alignmentStartTime) {
2050
+ this.alignmentStartTime = undefined;
1143
2051
  }
1144
- else if (bestBox.w < 150) {
1145
- this.statusMessage = "Identificación detectada. Acerque al marco";
1146
- this.statusColor = "#ffb366";
2052
+ if (this.alignmentTimer) {
2053
+ clearTimeout(this.alignmentTimer);
2054
+ this.alignmentTimer = undefined;
1147
2055
  }
1148
- else {
1149
- this.statusMessage = "Identificación detectada. Alinee con el marco";
1150
- this.statusColor = "#ffb366";
1151
- }
1152
- }
1153
- }
1154
- drawDetections(ctx, boxes) {
1155
- if (!this.videoRef)
1156
- return;
1157
- // Get video and container dimensions
1158
- const videoWidth = this.videoRef.videoWidth;
1159
- const videoHeight = this.videoRef.videoHeight;
1160
- const containerWidth = this.canvasRef.width;
1161
- const containerHeight = this.canvasRef.height;
1162
- if (videoWidth === 0 || videoHeight === 0)
1163
- return;
1164
- // Calculate video aspect ratio and container aspect ratio
1165
- const videoAspectRatio = videoWidth / videoHeight;
1166
- const containerAspectRatio = containerWidth / containerHeight;
1167
- // Determine how video fits in container (same logic as updateMaskDimensions)
1168
- let displayedVideoWidth, displayedVideoHeight;
1169
- let videoOffsetX = 0, videoOffsetY = 0;
1170
- if (videoAspectRatio > containerAspectRatio) {
1171
- // Video is wider - letterboxed (black bars top/bottom)
1172
- displayedVideoWidth = containerWidth;
1173
- displayedVideoHeight = containerWidth / videoAspectRatio;
1174
- videoOffsetY = (containerHeight - displayedVideoHeight) / 2;
1175
- }
1176
- else {
1177
- // Video is taller - pillarboxed (black bars left/right)
1178
- displayedVideoHeight = containerHeight;
1179
- displayedVideoWidth = containerHeight * videoAspectRatio;
1180
- videoOffsetX = (containerWidth - displayedVideoWidth) / 2;
1181
2056
  }
1182
- // Scale factor from model coordinates (320x320) to displayed video area
1183
- const scaleX = displayedVideoWidth / this.INPUT_SIZE;
1184
- const scaleY = displayedVideoHeight / this.INPUT_SIZE;
1185
- boxes.forEach(det => {
1186
- // Convert model coordinates to displayed video coordinates
1187
- const x = det.x * scaleX + videoOffsetX;
1188
- const y = det.y * scaleY + videoOffsetY;
1189
- const w = det.w * scaleX;
1190
- const h = det.h * scaleY;
1191
- ctx.strokeStyle = "#32406C";
1192
- ctx.lineWidth = 2;
1193
- ctx.strokeRect(x, y, w, h);
1194
- });
1195
2057
  }
1196
2058
  async takeScreenshot() {
1197
2059
  if (!this.videoRef || !this.lastDetectedBox)
1198
2060
  return;
1199
- // Activar animación
2061
+ this.logger.state('INICIANDO_CAPTURA', {
2062
+ captureStep: this.stateManager.getCaptureState().step,
2063
+ detectedBox: this.lastDetectedBox,
2064
+ videoResolution: {
2065
+ width: this.videoRef.videoWidth,
2066
+ height: this.videoRef.videoHeight
2067
+ }
2068
+ });
2069
+ this.stateManager.updateCaptureState({ isCapturing: true });
1200
2070
  this.triggerCaptureAnimation();
1201
- // OPTIMIZATION: Reuse capture canvas for full frame
1202
- if (!this.captureCanvas || !this.captureCtx) {
1203
- this.initializeCanvasPool();
1204
- }
1205
- // Resize reused canvas for full frame
1206
- this.captureCanvas.width = this.videoRef.videoWidth;
1207
- this.captureCanvas.height = this.videoRef.videoHeight;
1208
- this.captureCtx.drawImage(this.videoRef, 0, 0, this.captureCanvas.width, this.captureCanvas.height);
1209
- // Calcular las coordenadas de recorte basadas en la detección
1210
- const scaleX = this.videoRef.videoWidth / this.INPUT_SIZE;
1211
- const scaleY = this.videoRef.videoHeight / this.INPUT_SIZE;
2071
+ // Create capture canvas
2072
+ const captureCanvas = document.createElement('canvas');
2073
+ captureCanvas.width = this.videoRef.videoWidth;
2074
+ captureCanvas.height = this.videoRef.videoHeight;
2075
+ const captureCtx = captureCanvas.getContext('2d', { alpha: false });
2076
+ captureCtx.drawImage(this.videoRef, 0, 0, captureCanvas.width, captureCanvas.height);
2077
+ // Calculate crop coordinates
2078
+ const INPUT_SIZE = 320;
2079
+ const scaleX = this.videoRef.videoWidth / INPUT_SIZE;
2080
+ const scaleY = this.videoRef.videoHeight / INPUT_SIZE;
1212
2081
  const cropX = Math.max(0, (this.lastDetectedBox.x * scaleX) - this.cropMargin);
1213
2082
  const cropY = Math.max(0, (this.lastDetectedBox.y * scaleY) - this.cropMargin);
1214
2083
  const cropWidth = Math.min((this.lastDetectedBox.w * scaleX) + (2 * this.cropMargin), this.videoRef.videoWidth - cropX);
1215
2084
  const cropHeight = Math.min((this.lastDetectedBox.h * scaleY) + (2 * this.cropMargin), this.videoRef.videoHeight - cropY);
1216
- // OPTIMIZATION: Create temporary canvas only for cropped version
1217
- // (We reuse main capture canvas for full frame)
2085
+ // Create cropped version
1218
2086
  const croppedCanvas = document.createElement('canvas');
1219
2087
  croppedCanvas.width = cropWidth;
1220
2088
  croppedCanvas.height = cropHeight;
1221
2089
  const croppedCtx = croppedCanvas.getContext('2d', { alpha: false });
1222
- // Recortar la identificación del frame completo
1223
- croppedCtx.drawImage(this.videoRef, cropX, cropY, cropWidth, cropHeight, // Área de origen
1224
- 0, 0, cropWidth, cropHeight // Área de destino
1225
- );
1226
- if (this.captureStep === 'front') {
1227
- // Captura del frente usando canvas reutilizado
1228
- this.capturedFullFrame = this.captureCanvas.toDataURL('image/png');
1229
- this.capturedCroppedId = croppedCanvas.toDataURL('image/png');
2090
+ croppedCtx.drawImage(this.videoRef, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
2091
+ const captureState = this.stateManager.getCaptureState();
2092
+ if (captureState.step === 'front') {
2093
+ this.stateManager.setCapturedImages({
2094
+ front: {
2095
+ fullFrame: captureCanvas.toDataURL('image/png'),
2096
+ cropped: croppedCanvas.toDataURL('image/png')
2097
+ }
2098
+ });
1230
2099
  // Check if document classification is enabled
1231
2100
  if (this.useDocumentClassification) {
1232
- // Load MobileNet model if not loaded yet (lazy loading)
1233
- if (!this.mobileNetSession) {
1234
- try {
1235
- await this.loadMobileNetModel();
1236
- }
1237
- catch (error) {
1238
- this.debugLog('⚠️ Failed to load classification model, continuing without classification:', error);
1239
- }
1240
- }
1241
- // Classify the cropped document if model is available
1242
- if (this.mobileNetSession) {
1243
- const classification = await this.classifyDocument(croppedCanvas);
1244
- if (classification && classification.class === 'passport') {
1245
- // If it's a passport, skip back capture since passports don't have a back side
1246
- this.debugLog('📄 Passport detected - skipping back capture');
1247
- this.completeProcess(true);
1248
- return;
1249
- }
2101
+ const classification = await this.detectionService.classifyDocument(croppedCanvas);
2102
+ if (classification && classification.class === 'passport') {
2103
+ this.logger.state('PASAPORTE_DETECTADO_SALTANDO_REVERSO', { classification: classification?.class });
2104
+ this.completeProcess(true);
2105
+ return;
1250
2106
  }
1251
2107
  }
1252
- // For other IDs, continue with normal flow (back capture)
1253
- this.captureStep = 'back';
1254
- this.statusMessage = "Voltee la identificación y muestre la parte trasera";
1255
- this.statusColor = "#007bff";
1256
- // Pausar detección durante la animación de giro
1257
- this.isDetectionPaused = true;
1258
- // Mostrar animación de giro después de la captura
2108
+ this.stateManager.updateCaptureState({
2109
+ step: 'back',
2110
+ isDetectionPaused: true,
2111
+ showFlipAnimation: true
2112
+ });
1259
2113
  setTimeout(() => {
1260
- this.showFlipAnimation = true;
1261
- setTimeout(() => {
1262
- this.showFlipAnimation = false;
1263
- // Reanudar detección después de que termine la animación
1264
- this.isDetectionPaused = false;
1265
- }, 3000);
1266
- }, 800);
1267
- this.debugLog('📸 FRENTE capturado. Esperando trasera...');
1268
- }
1269
- else if (this.captureStep === 'back') {
1270
- // Captura de la trasera usando canvas reutilizado
1271
- this.capturedBackFullFrame = this.captureCanvas.toDataURL('image/png');
1272
- this.capturedBackCroppedId = croppedCanvas.toDataURL('image/png');
2114
+ this.stateManager.updateCaptureState({
2115
+ showFlipAnimation: false,
2116
+ isDetectionPaused: false
2117
+ });
2118
+ this.startBackDocumentTimer();
2119
+ }, 3000);
2120
+ }
2121
+ else if (captureState.step === 'back') {
2122
+ this.stateManager.setCapturedImages({
2123
+ back: {
2124
+ fullFrame: captureCanvas.toDataURL('image/png'),
2125
+ cropped: croppedCanvas.toDataURL('image/png')
2126
+ }
2127
+ });
1273
2128
  this.completeProcess(false);
1274
- this.debugLog('📸 TRASERA capturada. Proceso completado. Detector detenido. Imágenes emitidas.');
1275
2129
  }
1276
2130
  }
1277
2131
  triggerCaptureAnimation() {
1278
- this.isCapturing = true;
1279
- // Agregar clase de animación al marco
1280
2132
  const cardOutline = this.el.shadowRoot?.querySelector('.card-outline');
1281
2133
  cardOutline?.classList.add('capturing');
1282
- // Limpiar animación después de que termine
1283
2134
  setTimeout(() => {
1284
- this.isCapturing = false;
2135
+ this.stateManager.updateCaptureState({ isCapturing: false });
1285
2136
  cardOutline?.classList.remove('capturing');
1286
2137
  }, 600);
1287
2138
  }
2139
+ completeProcess(skippedBack = false) {
2140
+ this.stateManager.updateCaptureState({
2141
+ step: 'completed',
2142
+ showSuccessAnimation: true
2143
+ });
2144
+ const capturedImages = this.stateManager.getCapturedImages();
2145
+ capturedImages.metadata.processCompleted = true;
2146
+ capturedImages.metadata.backCaptureSkipped = skippedBack;
2147
+ this.stateManager.setCapturedImages(capturedImages);
2148
+ this.stopDetection();
2149
+ this.updateStatus('Proceso completado', `${capturedImages.metadata.totalImages} imágenes capturadas exitosamente`, 'ready');
2150
+ const finalImages = {
2151
+ ...capturedImages,
2152
+ timestamp: new Date().toISOString()
2153
+ };
2154
+ this.captureCompleted.emit(finalImages);
2155
+ setTimeout(() => {
2156
+ this.stateManager.updateCaptureState({ showSuccessAnimation: false });
2157
+ }, 3000);
2158
+ this.logger.state('PROCESO_COMPLETADO', {
2159
+ skippedBack,
2160
+ totalImages: capturedImages.metadata.totalImages,
2161
+ timestamp: new Date().toISOString()
2162
+ });
2163
+ }
1288
2164
  stopDetection() {
1289
2165
  if (this.animationId) {
1290
2166
  cancelAnimationFrame(this.animationId);
1291
2167
  this.animationId = undefined;
1292
2168
  }
1293
- // Limpiar canvas para eliminar cualquier detección visual residual
1294
- if (this.canvasRef) {
1295
- const ctx = this.canvasRef.getContext("2d");
1296
- ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
2169
+ this.clearBackDocumentTimer();
2170
+ this.detectionBoxes = [];
2171
+ this.logger.state('DETECTOR_DETENIDO', { timestamp: Date.now() });
2172
+ }
2173
+ startBackDocumentTimer() {
2174
+ if (!this.enableBackDocumentTimer)
2175
+ return;
2176
+ if (this.backDocumentTimer) {
2177
+ clearInterval(this.backDocumentTimer);
2178
+ }
2179
+ this.backDocumentTimerRemaining = this.backDocumentTimerDuration;
2180
+ this.backDocumentTimer = window.setInterval(() => {
2181
+ this.backDocumentTimerRemaining--;
2182
+ if (this.backDocumentTimerRemaining <= 0) {
2183
+ this.clearBackDocumentTimer();
2184
+ this.skipBackCapture();
2185
+ }
2186
+ }, 1000);
2187
+ }
2188
+ clearBackDocumentTimer() {
2189
+ if (this.backDocumentTimer) {
2190
+ clearInterval(this.backDocumentTimer);
2191
+ this.backDocumentTimer = undefined;
2192
+ }
2193
+ this.backDocumentTimerRemaining = 0;
2194
+ }
2195
+ toggleCameraSelector() {
2196
+ if (this.isSwitchingCamera)
2197
+ return; // Don't toggle if switching camera
2198
+ this.showCameraSelector = !this.showCameraSelector;
2199
+ }
2200
+ async handleCameraSwitch(cameraId) {
2201
+ if (this.isSwitchingCamera)
2202
+ return; // Prevent multiple simultaneous switches
2203
+ try {
2204
+ // Close the selector immediately when user selects a camera
2205
+ this.showCameraSelector = false;
2206
+ this.isSwitchingCamera = true;
2207
+ this.logger.state('INICIANDO_CAMBIO_CAMARA', {
2208
+ from: this.cameraService.getSelectedCameraId(),
2209
+ to: cameraId
2210
+ });
2211
+ // Stop current video stream
2212
+ if (this.videoStream) {
2213
+ this.videoStream.getTracks().forEach(track => track.stop());
2214
+ }
2215
+ // Switch camera in service
2216
+ await this.cameraService.switchCamera(cameraId);
2217
+ // Setup new camera stream
2218
+ const newStream = await this.cameraService.setupCamera();
2219
+ // Update video element
2220
+ await this.initializeVideoStream(newStream);
2221
+ this.logger.state('CAMBIO_CAMARA_EXITOSO', {
2222
+ newCameraId: cameraId,
2223
+ isRearCamera: this.cameraService.isRearCamera(newStream)
2224
+ });
2225
+ }
2226
+ catch (error) {
2227
+ this.logger.error('Error al cambiar cámara:', error);
2228
+ // Try to restore previous camera if switch failed
2229
+ try {
2230
+ const fallbackStream = await this.cameraService.setupCamera();
2231
+ await this.initializeVideoStream(fallbackStream);
2232
+ }
2233
+ catch (fallbackError) {
2234
+ this.logger.error('Error al restaurar cámara anterior:', fallbackError);
2235
+ this.updateStatus('Error al cambiar cámara', 'No se pudo completar el cambio de dispositivo', 'error');
2236
+ }
2237
+ }
2238
+ finally {
2239
+ this.isSwitchingCamera = false;
1297
2240
  }
1298
- this.debugLog('🛑 Detector de identificación detenido');
1299
2241
  }
1300
2242
  resetDetection() {
1301
- this.bestScore = 0;
1302
- this.startTime = Date.now();
1303
- this.statusMessage = "Sistema reiniciado. Posicione la identificación";
1304
- this.statusColor = "#aaa";
2243
+ const currentCaptureState = this.stateManager.getCaptureState();
2244
+ const wasVideoActive = currentCaptureState.isVideoActive;
2245
+ this.stateManager.reset();
2246
+ if (wasVideoActive) {
2247
+ this.stateManager.updateCaptureState({ isVideoActive: true });
2248
+ }
1305
2249
  this.hasScreenshotTaken = false;
1306
- this.capturedFullFrame = null;
1307
- this.capturedCroppedId = null;
1308
- this.capturedBackFullFrame = null;
1309
- this.capturedBackCroppedId = null;
1310
- this.captureStep = 'front';
1311
- this.isCapturing = false;
1312
- this.showFlipAnimation = false;
1313
- this.showSuccessAnimation = false;
1314
- this.isDetectionPaused = false;
1315
- this.isLoading = false;
1316
- this.lastDetectedBox = undefined;
1317
- this.isModelPreloaded = false;
1318
- // Reset performance counters
2250
+ this.startTime = Date.now();
1319
2251
  this.frameSkipCounter = 0;
1320
2252
  this.consecutiveFailures = 0;
1321
2253
  this.lastInferenceTime = 0;
1322
- if (this.canvasRef) {
1323
- const ctx = this.canvasRef.getContext("2d");
1324
- ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
1325
- }
1326
- // Reiniciar el detector si estaba funcionando
1327
- if (this.isVideoActive && this.session) {
2254
+ this.detectionBoxes = [];
2255
+ this.alignmentStartTime = undefined;
2256
+ this.hasDocumentDetected = false;
2257
+ if (this.alignmentTimer) {
2258
+ clearTimeout(this.alignmentTimer);
2259
+ this.alignmentTimer = undefined;
2260
+ }
2261
+ if (wasVideoActive && this.detectionService.isModelLoaded()) {
2262
+ this.updateStatus('Captura reiniciada', 'Buscando documento en el marco de captura', 'active');
1328
2263
  this.detectFrame();
1329
2264
  }
1330
- }
1331
- completeProcess(skippedBack = false) {
1332
- this.captureStep = 'completed';
1333
- this.statusMessage = skippedBack ?
1334
- "Proceso completado (solo frente capturado)" :
1335
- "Proceso de captura completado exitosamente";
1336
- this.statusColor = "#28a745";
1337
- // Detener el detector
1338
- this.stopDetection();
1339
- // Emitir evento con las imágenes capturadas
1340
- const capturedImages = {
1341
- front: {
1342
- fullFrame: this.capturedFullFrame,
1343
- cropped: this.capturedCroppedId
1344
- },
1345
- back: {
1346
- fullFrame: this.capturedBackFullFrame,
1347
- cropped: this.capturedBackCroppedId
1348
- },
1349
- timestamp: new Date().toISOString(),
1350
- metadata: {
1351
- totalImages: skippedBack ? 2 : 4,
1352
- processCompleted: true,
1353
- backCaptureSkipped: skippedBack
1354
- }
1355
- };
1356
- this.captureCompleted.emit(capturedImages);
1357
- // Mostrar animación de éxito después de la captura
1358
- setTimeout(() => {
1359
- this.showSuccessAnimation = true;
1360
- setTimeout(() => {
1361
- this.showSuccessAnimation = false;
1362
- }, 3000);
1363
- }, 800);
2265
+ else {
2266
+ this.updateStatus('Listo para capturar', '', 'ready');
2267
+ }
1364
2268
  }
1365
2269
  exitSession() {
1366
2270
  if (this.videoStream) {
1367
2271
  this.videoStream.getTracks().forEach(track => track.stop());
1368
2272
  this.videoStream = undefined;
1369
- this.isVideoActive = false;
1370
- this.statusMessage = "Sesión finalizada.";
1371
- this.statusColor = "#aaa";
1372
- }
1373
- this.isLoading = false;
1374
- // Limpiar canvas al finalizar sesión
1375
- if (this.canvasRef) {
1376
- const ctx = this.canvasRef.getContext("2d");
1377
- ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
2273
+ this.stateManager.updateCaptureState({ isVideoActive: false, isLoading: false });
1378
2274
  }
2275
+ this.isMaskReady = false;
2276
+ this.updateStatus('Sesión finalizada', '', 'ready');
2277
+ this.detectionBoxes = [];
1379
2278
  this.cleanup();
1380
2279
  }
1381
- render() {
1382
- return (index.h("div", { key: 'b90673f523dbf112786eb7184f56415055765c0c', class: "detector-container" }, index.h("div", { key: '925c5c886e9af53b460281244958e722de670a70', class: "video-container" }, index.h("video", { key: '4a0074e33a81b4694c6c99e3ece256c1ac8fe1d5', ref: el => this.videoRef = el, autoplay: true, muted: true, playsinline: true, class: this.shouldMirrorVideo ? 'mirror' : '', style: { display: this.isVideoActive ? 'block' : 'none' } }), index.h("canvas", { key: 'a7b7f3b35c0535c11f6a4a155502dbe016f590b7', ref: el => this.canvasRef = el, class: this.shouldMirrorVideo ? 'mirror' : '' }), this.isMaskReady && (index.h("div", { key: '695b6a2f97e29b0b9c5b9d43682a951caff376a1', class: "overlay-mask" }, index.h("div", { key: '768875c39d5917c6a04306037d03d9e1aed114bd', class: "card-outline" }, index.h("div", { key: 'a56536deb7b8c99145d1bef6fee84e5801798644', class: "side side-top" }), index.h("div", { key: 'df413058c7e787f9c8a95a429a4f7186de07f5f6', class: "side side-right" }), index.h("div", { key: '37e074e3d4c914457f97720f9ab1f1ac2c70c4f1', class: "side side-bottom" }), index.h("div", { key: '772d3d40925c75ba5d39e2e7dd6405f7cc88d292', class: "side side-left" }), index.h("div", { key: 'd4e624782b3f4b4b8dbb8286b3acf003e433928c', class: "corner corner-tl" }), index.h("div", { key: '63f0ccfbbe3c1d413ca6912f36cab4afa6334bb2', class: "corner corner-tr" }), index.h("div", { key: '20012a50fe3d98ddfa669f5283a871c757522797', class: "corner corner-bl" }), index.h("div", { key: '9d82646bd08a6ca2b8c5782629fc68fd93cf1287', class: "corner corner-br" }), !this.showFlipAnimation && !this.showSuccessAnimation && (index.h("div", { key: '79fcbdb52e434a45aa6f2748d6d84500beb2e844', class: "guide-text" }, this.statusMessage))), this.captureStep === 'back' && !this.showFlipAnimation && !this.showSuccessAnimation && (index.h("button", { key: '16e587d645e2489a3410fe80bfc014738f08f02f', class: "skip-button", onClick: () => this.skipBackCapture(), type: "button" }, "Saltar reverso")), this.isVideoActive && (index.h("div", { key: 'd46d6881a9e1b63bdd849f5a8c6e96a2caefdefb', class: "camera-controls" }, this.isMultipleCamerasAvailable && (index.h("button", { key: 'afb4b44d691471f8af6e894e061aecb6c82259da', class: "flip-camera-button", onClick: () => this.flipCamera(), type: "button", title: "Cambiar c\u00E1mara" }, "Girar c\u00E1mara")), index.h("button", { key: '4e0680c2da9e6cc70b5880e8f5db9830c080acf9', class: "camera-selector-button", onClick: () => this.toggleCameraSelector(), type: "button", title: "Seleccionar c\u00E1mara" }, "C\u00E1maras"), this.debug && (index.h("div", { key: 'fc59922452a216273f742d3158559f6b2a1da43e', style: {
1383
- position: 'absolute',
1384
- top: '50px',
1385
- right: '0',
1386
- background: 'rgba(0,0,0,0.8)',
1387
- color: 'white',
1388
- padding: '8px',
1389
- fontSize: '10px',
1390
- borderRadius: '4px',
1391
- whiteSpace: 'nowrap'
1392
- } }, "C\u00E1maras: ", this.availableCameras.length, index.h("br", { key: '8f112283d1e0518a395dc6d94e1caa46fee0a86b' }), "M\u00FAltiples: ", this.isMultipleCamerasAvailable ? 'Sí' : 'No', index.h("br", { key: 'aacfd3ee868ee5d0819ce5ef693e3ef384867332' }), "Selector: ", this.showCameraSelector ? 'Visible' : 'Oculto', index.h("br", { key: '9927df7f56583c7388cee25e13526efc7e032eb1' }), "Video: ", this.isVideoActive ? 'Activo' : 'Inactivo')))), this.showCameraSelector && this.availableCameras.length > 0 && (index.h("div", { key: 'c2914bf7f3175b8f0ddab977abbdefe3022d7d29', class: "camera-selector-dropdown" }, index.h("div", { key: '0b5397e77ff33944f3620d78b1ab41275dffd7ac', class: "camera-selector-header" }, index.h("span", { key: '78f0fb8e34a0cf2d16138522a98cbdb9eefd671f' }, "Seleccionar C\u00E1mara"), index.h("button", { key: '0d4ea7b191d61d1d9b9c35462ce336148be73fcb', class: "close-selector", onClick: () => this.toggleCameraSelector(), type: "button" }, "\u00D7")), index.h("div", { key: 'c16c7a106493ef7633a4cd35b06a4f45327a2e23', class: "camera-list" }, this.availableCameras.map((camera) => (index.h("button", { key: camera.deviceId, class: `camera-option ${this.selectedCameraId === camera.deviceId ? 'selected' : ''}`, onClick: () => {
1393
- this.switchCamera(camera.deviceId);
1394
- this.toggleCameraSelector();
1395
- }, type: "button" }, index.h("span", { class: "camera-label" }, camera.label || `Cámara ${this.availableCameras.indexOf(camera) + 1}`), this.selectedCameraId === camera.deviceId && (index.h("span", { class: "selected-indicator" }, "\u2713")))))), index.h("div", { key: '11d6f980c3995a8414da5f7e6921f963ca72aed4', class: "device-info" }, index.h("small", { key: '78f5ff030f9e6e5021234a246599eb459b05d9f7' }, "Dispositivo: ", this.deviceType)))))), this.isCapturing && (index.h("div", { key: 'ef7d32491d8bdb94b2d6456420c2ddbc80f03d81', class: "capture-animation" })), this.showFlipAnimation && (index.h("div", { key: 'cfb5868ee75a9d1d1604c52f6fce1241090e4ac2', class: "flip-animation" }, index.h("div", { key: '1b41035e63f88434aba6699d4e68e06bea2c1529', class: "id-card-icon" }), index.h("div", { key: '4e83af9d0d39ce37b7448c4a0783b60b42b858e2', class: "flip-text" }, "\u00A1Voltea tu identificaci\u00F3n!"))), this.showSuccessAnimation && (index.h("div", { key: 'c7911e8be74b0fc7d7d18d7dcb52374645ca5f12', class: "success-animation" }, index.h("div", { key: '18e5ea5cfa3eb35fe3b99c12e6fab8e27e09e85e', class: "check-icon" }), index.h("div", { key: '490d8fcd6540c3b4fdc95f7fb5e6f6697722c9ac', class: "success-text" }, "\u00A1Proceso completado!"))), this.isLoading && (index.h("div", { key: 'f7fbfec6be2d8e0dc4e522b85b7d67897575cf0e', class: "loading-overlay" }, index.h("div", { key: '8282b92f19da6a9c7d22a64a0929b2b4d67c46be', class: "loading-spinner" }), index.h("div", { key: '7880b689297eb9d80a9b0023ba7933148fd8710a', class: "loading-text" }, this.statusMessage))), index.h("div", { key: '887c951fb03097171d9bd466fe24e36d11a49954', class: "watermark" }, index.h("img", { key: '314c9c172a4c6795c825741ea6f02b0e1186e93d', src: "https://storage.googleapis.com/jaak-static/commons/powered-by-jaak.png", alt: "Powered by Jaak" })))));
2280
+ // PERFORMANCE MONITORING METHODS
2281
+ initializePerformanceMonitor() {
2282
+ this.performanceMetrics.lastUpdateTime = performance.now();
2283
+ // Update performance metrics every 500ms
2284
+ this.performanceUpdateInterval = window.setInterval(() => {
2285
+ this.updatePerformanceMetrics();
2286
+ }, 500);
2287
+ this.logger.debug('Monitor de performance inicializado');
2288
+ }
2289
+ updatePerformanceMetrics() {
2290
+ const currentTime = performance.now();
2291
+ const deltaTime = currentTime - this.performanceMetrics.lastUpdateTime;
2292
+ // Calculate FPS
2293
+ if (deltaTime > 0) {
2294
+ this.performanceMetrics.fps = Math.round(1000 / (deltaTime / this.frameSkipCounter || 1));
2295
+ }
2296
+ // Get memory usage if available
2297
+ if ('memory' in performance) {
2298
+ const memInfo = performance.memory;
2299
+ this.performanceMetrics.memoryUsage = Math.round(memInfo.usedJSHeapSize / 1048576); // MB
2300
+ }
2301
+ // Calculate detection success rate
2302
+ if (this.performanceMetrics.totalDetections > 0) {
2303
+ this.performanceMetrics.successfulDetections = this.performanceMetrics.successfulDetections;
2304
+ const detectionRate = (this.performanceMetrics.successfulDetections / this.performanceMetrics.totalDetections) * 100;
2305
+ this.performanceMetrics.detectionRate = Math.round(detectionRate);
2306
+ }
2307
+ // Update state for rendering
2308
+ this.performanceData = {
2309
+ fps: this.performanceMetrics.fps,
2310
+ inferenceTime: this.performanceMetrics.inferenceTime,
2311
+ memoryUsage: this.performanceMetrics.memoryUsage,
2312
+ onnxLoadTime: this.performanceMetrics.onnxLoadTime,
2313
+ frameProcessingTime: this.performanceMetrics.frameProcessingTime,
2314
+ totalDetections: this.performanceMetrics.totalDetections,
2315
+ successfulDetections: this.performanceMetrics.successfulDetections,
2316
+ detectionRate: this.performanceMetrics.detectionRate
2317
+ };
2318
+ this.performanceMetrics.lastUpdateTime = currentTime;
2319
+ }
2320
+ recordOnnxPerformance(loadTime, inferenceTime) {
2321
+ this.performanceMetrics.onnxLoadTime = Math.round(loadTime);
2322
+ this.performanceMetrics.inferenceTime = Math.round(inferenceTime);
2323
+ }
2324
+ recordFrameProcessing(processingTime, detectionsFound) {
2325
+ this.performanceMetrics.frameProcessingTime = Math.round(processingTime);
2326
+ this.performanceMetrics.totalDetections++;
2327
+ if (detectionsFound > 0) {
2328
+ this.performanceMetrics.successfulDetections++;
2329
+ }
1396
2330
  }
1397
2331
  };
1398
2332
  JaakStamps.style = myComponentCss;