id-ocr-engine / static /js /camera.js
Kevin-KES
Deploy current main to HF Space
50c6ee2
Raw
History Blame Contribute Delete
20.2 kB
window.Camera = {
_stream: null,
_devices: [],
_currentDeviceIndex: 0,
_activeSide: null,
_bound: null,
_isScanning: false,
_scanAborted: false,
_scanAttempts: 0,
_countdownTimer: null,
_barcodeDetector: null,
_MAX_SERIAL_ATTEMPTS: 15,
_SERIAL_TIPS: [
'Hold the label steady and try again...',
'Try moving closer to the label...',
'Ensure good lighting on the label...',
'Tilt the device to reduce glare...',
],
open: function (side) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
Utils.showToast('Camera is not supported in this browser.', 'error');
return;
}
var self = this;
this._activeSide = side;
this._isScanning = false;
this._scanAborted = false;
this._scanAttempts = 0;
// Initialize client-side barcode detector if available
if (window.BarcodeDetector) {
try {
this._barcodeDetector = new BarcodeDetector({ formats: ['pdf417', 'code_39'] });
} catch (e) {
this._barcodeDetector = null;
}
}
Utils.$('#camera-modal').classList.remove('hidden');
Utils.$('#camera-loading').classList.remove('hidden');
Utils.$('#camera-capture-btn').disabled = true;
Utils.$('#camera-scan-btn').disabled = true;
this._setScanStatus('');
this._bindEvents();
var constraints = {
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
};
this._startStream(constraints)
.then(function () {
return self._enumerateDevices();
})
.then(function () {
self._updateSwitchButton();
self.startScan();
})
.catch(function (err) {
self._handleError(err);
self.close();
});
},
close: function () {
this.stopScan();
this._stopStream();
Utils.$('#camera-modal').classList.add('hidden');
Utils.$('#camera-scan-guide').classList.add('hidden');
var video = Utils.$('#camera-video');
if (video) video.srcObject = null;
this._activeSide = null;
this._unbindEvents();
},
capture: function () {
var self = this;
var video = Utils.$('#camera-video');
if (!video || !video.videoWidth) {
Utils.showToast('Camera not ready. Please wait.', 'warning');
return;
}
var canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
var ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
this._canvasToFile(canvas).then(function (file) {
Upload._setFile(self._activeSide, file);
self.close();
Utils.showToast('Photo captured', 'success');
});
},
// ── Auto-Scan ──────────────────────────────────────────
startScan: function () {
if (this._isScanning) return;
this._isScanning = true;
this._scanAborted = false;
this._scanAttempts = 0;
var docType = Utils.$('#doc-type-select').value;
var isSerial = docType === 'product_serial';
Utils.$('#camera-scan-btn').textContent = 'Stop Scan';
Utils.$('#camera-scan-btn').classList.add('btn-scanning');
Utils.$('#camera-capture-btn').disabled = true;
Utils.$('#camera-scan-status').classList.remove('hidden');
Utils.$('#camera-video').classList.add('scanning');
// Show scan guide for serial number mode
var guide = Utils.$('#camera-scan-guide');
if (isSerial) {
guide.classList.remove('hidden');
} else {
guide.classList.add('hidden');
}
this._setScanStatus(isSerial ? 'Position serial number in the frame...' : 'Hold your ID steady...');
this._scanLoop();
},
stopScan: function () {
if (!this._isScanning) return;
this._isScanning = false;
this._scanAborted = true;
this._clearCountdown();
Utils.$('#camera-scan-btn').textContent = '\uD83D\uDD0D Scan ID';
Utils.$('#camera-scan-btn').classList.remove('btn-scanning');
Utils.$('#camera-capture-btn').disabled = false;
Utils.$('#camera-scan-status').classList.add('hidden');
Utils.$('#camera-video').classList.remove('scanning');
Utils.$('#camera-scan-guide').classList.add('hidden');
},
toggleScan: function () {
if (this._isScanning) {
this.stopScan();
} else {
this.startScan();
}
},
_scanLoop: function () {
if (!this._isScanning || this._scanAborted) return;
var self = this;
var video = Utils.$('#camera-video');
if (!video || !video.videoWidth) {
setTimeout(function () { self._scanLoop(); }, 1000);
return;
}
this._scanAttempts++;
var docType = Utils.$('#doc-type-select').value;
var isSerialMode = docType === 'product_serial';
var isBarcodeMode = this._activeSide === 'back' && !Upload.frontFile;
// ── Serial number scanning ───────────────────────────
if (isSerialMode) {
if (this._scanAttempts > this._MAX_SERIAL_ATTEMPTS) {
this.stopScan();
Utils.showToast('Could not detect serial number. Try better lighting or move closer.', 'warning', 5000);
return;
}
this._setScanStatus('Scanning... (attempt ' + this._scanAttempts + '/' + this._MAX_SERIAL_ATTEMPTS + ')');
// Crop to the center 60% of the frame (matches scan guide overlay)
var cropW = Math.round(video.videoWidth * 0.6);
var cropH = Math.round(video.videoHeight * 0.4);
var cropX = Math.round((video.videoWidth - cropW) / 2);
var cropY = Math.round((video.videoHeight - cropH) / 2);
var canvas = document.createElement('canvas');
canvas.width = cropW;
canvas.height = cropH;
canvas.getContext('2d').drawImage(
video, cropX, cropY, cropW, cropH,
0, 0, cropW, cropH
);
this._canvasToFile(canvas).then(function (file) {
if (!self._isScanning || self._scanAborted) return;
self._setScanStatus('Processing...');
Api.scanSerial(file).then(function (result) {
if (!self._isScanning || self._scanAborted) return;
if (result.serial_number) {
self._isScanning = false;
Upload._setFile('front', file);
Results.renderSerial(result, file);
History.add(History.buildEntry(result, file, 0, null));
self.close();
Utils.showToast('Serial number detected: ' + result.serial_number, 'success');
} else {
var tip = self._SERIAL_TIPS[self._scanAttempts % self._SERIAL_TIPS.length];
self._setScanStatus(tip);
setTimeout(function () { self._scanLoop(); }, 2000);
}
}).catch(function (err) {
if (!self._isScanning || self._scanAborted) return;
if (err.status === 429) {
self._startCountdown(10, function () { self._scanLoop(); });
} else {
self._setScanStatus('Error, retrying...');
setTimeout(function () { self._scanLoop(); }, 3000);
}
});
});
return;
}
// ── Client-side barcode detection (fast, decodes PDF417) ──
if (isBarcodeMode && this._barcodeDetector) {
this._setScanStatus('Looking for barcode... (attempt ' + this._scanAttempts + ')');
this._barcodeDetector.detect(video).then(function (barcodes) {
if (!self._isScanning || self._scanAborted) return;
if (!barcodes.length) {
setTimeout(function () { self._scanLoop(); }, 1000);
return;
}
var rawText = barcodes[0].rawValue;
self._setScanStatus('Barcode detected! Processing...');
Api.parseBarcode(rawText).then(function (result) {
if (!self._isScanning || self._scanAborted) return;
var fields = result.fields || {};
var hasFields = Object.keys(fields).some(function (k) { return !!fields[k]; });
var canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
self._canvasToFile(canvas).then(function (file) {
self._isScanning = false;
Upload._setFile(self._activeSide, file);
Results.render(result, file);
History.add(History.buildEntry(result, file, 0, null));
self.close();
Utils.showToast(
hasFields ? 'Barcode scanned successfully' : 'Barcode detected but could not extract data',
hasFields ? 'success' : 'warning'
);
});
}).catch(function () {
if (!self._isScanning || self._scanAborted) return;
self._setScanStatus('Error, retrying...');
setTimeout(function () { self._scanLoop(); }, 2000);
});
}).catch(function () {
if (!self._isScanning || self._scanAborted) return;
setTimeout(function () { self._scanLoop(); }, 1500);
});
return;
}
// ── Server-side scan (front side or no barcode detector) ─
this._setScanStatus('Scanning... (attempt ' + this._scanAttempts + ')');
var canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
var ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
var docType = Utils.$('#doc-type-select').value;
this._canvasToFile(canvas).then(function (file) {
if (!self._isScanning || self._scanAborted) return;
self._setScanStatus('Processing... (attempt ' + self._scanAttempts + ')');
var apiCall;
if (self._activeSide === 'back' && Upload.frontFile) {
apiCall = Api.processDocument(Upload.frontFile, docType, file);
} else {
apiCall = Api.processDocument(file, docType, null);
}
return apiCall
.then(function (result) {
if (!self._isScanning || self._scanAborted) return;
var fields = result.fields || {};
var hasFields = Object.keys(fields).some(function (k) {
return !!fields[k];
});
if (isBarcodeMode && !hasFields) {
self._setScanStatus('No data extracted, retrying...');
setTimeout(function () { self._scanLoop(); }, 2000);
return;
}
self._isScanning = false;
Upload._setFile(self._activeSide, file);
Results.render(result, file);
History.add(History.buildEntry(result, file, 0, null));
self.close();
Utils.showToast(
hasFields ? 'ID scanned successfully' : 'Scan complete — try better lighting for more details',
hasFields ? 'success' : 'warning'
);
})
.catch(function (err) {
if (!self._isScanning || self._scanAborted) return;
if (err.status === 429) {
self._setScanStatus('Rate limited, waiting...');
setTimeout(function () { self._scanLoop(); }, 10000);
} else {
self._setScanStatus('Error, retrying...');
setTimeout(function () { self._scanLoop(); }, 3000);
}
});
});
},
_setScanStatus: function (text) {
var el = Utils.$('#camera-scan-text');
if (el) el.textContent = text;
},
_startCountdown: function (seconds, callback) {
var self = this;
var remaining = seconds;
this._clearCountdown();
this._setScanStatus('Rate limited, retrying in ' + remaining + 's...');
this._countdownTimer = setInterval(function () {
remaining--;
if (remaining <= 0) {
self._clearCountdown();
callback();
} else {
self._setScanStatus('Rate limited, retrying in ' + remaining + 's...');
}
}, 1000);
},
_clearCountdown: function () {
if (this._countdownTimer) {
clearInterval(this._countdownTimer);
this._countdownTimer = null;
}
},
// ── Camera Controls ────────────────────────────────────
switchCamera: function () {
if (this._devices.length < 2) return;
var self = this;
this._currentDeviceIndex = (this._currentDeviceIndex + 1) % this._devices.length;
var device = this._devices[this._currentDeviceIndex];
Utils.$('#camera-capture-btn').disabled = true;
Utils.$('#camera-loading').classList.remove('hidden');
this._startStream({
video: { deviceId: { exact: device.deviceId } },
audio: false,
}).catch(function (err) {
self._handleError(err);
return self._startStream({ video: true, audio: false });
});
},
// ── Private ────────────────────────────────────────────
_startStream: function (constraints) {
var self = this;
if (this._stream) {
this._stopStream();
}
return navigator.mediaDevices.getUserMedia(constraints)
.then(function (stream) {
self._stream = stream;
var video = Utils.$('#camera-video');
video.srcObject = stream;
return new Promise(function (resolve) {
video.onloadedmetadata = function () {
video.play().then(resolve).catch(resolve);
};
setTimeout(resolve, 3000);
});
})
.then(function () {
Utils.$('#camera-loading').classList.add('hidden');
Utils.$('#camera-capture-btn').disabled = false;
Utils.$('#camera-scan-btn').disabled = false;
});
},
_stopStream: function () {
if (!this._stream) return;
var tracks = this._stream.getTracks();
for (var i = 0; i < tracks.length; i++) {
tracks[i].stop();
}
this._stream = null;
},
_enumerateDevices: function () {
var self = this;
return navigator.mediaDevices.enumerateDevices().then(function (devices) {
self._devices = devices.filter(function (d) {
return d.kind === 'videoinput';
});
return self._devices;
});
},
_canvasToFile: function (canvas) {
return new Promise(function (resolve) {
canvas.toBlob(function (blob) {
var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
var file = new File([blob], 'camera-' + ts + '.jpg', {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(file);
}, 'image/jpeg', 0.92);
});
},
_updateSwitchButton: function () {
var btn = Utils.$('#camera-switch-btn');
if (this._devices.length > 1) {
btn.classList.remove('hidden');
} else {
btn.classList.add('hidden');
}
},
_handleError: function (err) {
var message;
switch (err.name) {
case 'NotAllowedError':
message = 'Camera access denied. Please allow camera permission in your browser settings.';
break;
case 'NotFoundError':
message = 'No camera found on this device.';
break;
case 'NotReadableError':
message = 'Camera is already in use by another application.';
break;
case 'OverconstrainedError':
message = 'Camera does not support the requested settings.';
break;
case 'AbortError':
message = 'Camera access was interrupted.';
break;
case 'SecurityError':
message = 'Camera access requires HTTPS or localhost.';
break;
default:
message = 'Could not access camera: ' + (err.message || 'Unknown error');
}
Utils.showToast(message, 'error', 6000);
},
_bindEvents: function () {
var self = this;
this._bound = {
capture: function () { self.capture(); },
scan: function () { self.toggleScan(); },
switchCam: function () { self.switchCamera(); },
close: function () { self.close(); },
backdrop: function (e) {
if (e.target.classList.contains('camera-modal-backdrop')) {
self.close();
}
},
escape: function (e) {
if (e.key === 'Escape') {
self.close();
}
},
};
Utils.$('#camera-capture-btn').addEventListener('click', this._bound.capture);
Utils.$('#camera-scan-btn').addEventListener('click', this._bound.scan);
Utils.$('#camera-switch-btn').addEventListener('click', this._bound.switchCam);
Utils.$('#camera-close-btn').addEventListener('click', this._bound.close);
Utils.$('#camera-cancel-btn').addEventListener('click', this._bound.close);
Utils.$('.camera-modal-backdrop').addEventListener('click', this._bound.backdrop);
document.addEventListener('keydown', this._bound.escape);
},
_unbindEvents: function () {
if (!this._bound) return;
Utils.$('#camera-capture-btn').removeEventListener('click', this._bound.capture);
Utils.$('#camera-scan-btn').removeEventListener('click', this._bound.scan);
Utils.$('#camera-switch-btn').removeEventListener('click', this._bound.switchCam);
Utils.$('#camera-close-btn').removeEventListener('click', this._bound.close);
Utils.$('#camera-cancel-btn').removeEventListener('click', this._bound.close);
Utils.$('.camera-modal-backdrop').removeEventListener('click', this._bound.backdrop);
document.removeEventListener('keydown', this._bound.escape);
this._bound = null;
},
};