const video = document.getElementById('video'); const previewCanvas = document.getElementById('preview-canvas'); const previewImg = document.getElementById('preview-img'); const captureCanvas = document.getElementById('capture-canvas'); const cameraStatus = document.getElementById('camera-status'); const studentNumberInput = document.getElementById('student-number'); const inputError = document.getElementById('input-error'); const captureBtn = document.getElementById('capture-btn'); const downloadLink = document.getElementById('download-link'); const zoomControl = document.getElementById('zoom-control'); const zoomValue = document.getElementById('zoom-value'); const panXControl = document.getElementById('pan-x-control'); const panYControl = document.getElementById('pan-y-control'); const focusControls = document.getElementById('focus-controls'); const focusUnsupported = document.getElementById('focus-unsupported'); const focusAutoBtn = document.getElementById('focus-auto-btn'); const focusManualBtn = document.getElementById('focus-manual-btn'); const focusControl = document.getElementById('focus-control'); const OUTPUT_WIDTH = 145; const OUTPUT_HEIGHT = 185; const TARGET_RATIO = OUTPUT_WIDTH / OUTPUT_HEIGHT; const PREVIEW_SCALE = 3; let captured = false; let previewLoopId = null; let videoTrack = null; function computeCropRect(videoWidth, videoHeight, zoom, panX, panY) { const videoRatio = videoWidth / videoHeight; let baseSw, baseSh; if (videoRatio > TARGET_RATIO) { baseSh = videoHeight; baseSw = baseSh * TARGET_RATIO; } else { baseSw = videoWidth; baseSh = baseSw / TARGET_RATIO; } const sw = baseSw / zoom; const sh = baseSh / zoom; const maxOffsetX = (videoWidth - sw) / 2; const maxOffsetY = (videoHeight - sh) / 2; const cx = videoWidth / 2 + panX * maxOffsetX; const cy = videoHeight / 2 + panY * maxOffsetY; return { sx: cx - sw / 2, sy: cy - sh / 2, sw, sh }; } function currentCropRect() { return computeCropRect( video.videoWidth, video.videoHeight, parseFloat(zoomControl.value), parseFloat(panXControl.value), parseFloat(panYControl.value) ); } function renderPreviewFrame() { if (!captured && video.videoWidth) { const { sx, sy, sw, sh } = currentCropRect(); const ctx = previewCanvas.getContext('2d'); ctx.save(); ctx.translate(previewCanvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, sx, sy, sw, sh, 0, 0, previewCanvas.width, previewCanvas.height); ctx.restore(); } previewLoopId = requestAnimationFrame(renderPreviewFrame); } async function startCamera() { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); video.srcObject = stream; videoTrack = stream.getVideoTracks()[0]; await new Promise((resolve) => video.addEventListener('loadedmetadata', resolve, { once: true })); previewCanvas.width = OUTPUT_WIDTH * PREVIEW_SCALE; previewCanvas.height = OUTPUT_HEIGHT * PREVIEW_SCALE; cameraStatus.classList.add('hidden'); captureBtn.disabled = false; renderPreviewFrame(); setupFocusControls(videoTrack); } catch (err) { cameraStatus.textContent = 'Unable to access camera: ' + err.message; } } function setupFocusControls(track) { if (!track.getCapabilities) { focusUnsupported.classList.remove('hidden'); return; } const capabilities = track.getCapabilities(); if (!capabilities.focusDistance || !capabilities.focusMode) { focusUnsupported.classList.remove('hidden'); return; } focusControls.classList.remove('hidden'); focusControl.min = capabilities.focusDistance.min; focusControl.max = capabilities.focusDistance.max; focusControl.step = capabilities.focusDistance.step || 0.01; focusControl.value = capabilities.focusDistance.min; const supportsAuto = capabilities.focusMode.includes('continuous'); focusAutoBtn.disabled = !supportsAuto; if (!supportsAuto) focusAutoBtn.classList.add('opacity-50', 'cursor-not-allowed'); function setActiveButton(manual) { focusManualBtn.classList.toggle('bg-blue-600', manual); focusManualBtn.classList.toggle('text-white', manual); focusManualBtn.classList.toggle('bg-white', !manual); focusManualBtn.classList.toggle('dark:bg-gray-700', !manual); focusManualBtn.classList.toggle('text-gray-700', !manual); focusManualBtn.classList.toggle('dark:text-gray-200', !manual); focusAutoBtn.classList.toggle('bg-blue-600', !manual); focusAutoBtn.classList.toggle('text-white', !manual); focusAutoBtn.classList.toggle('bg-white', manual); focusAutoBtn.classList.toggle('dark:bg-gray-700', manual); focusAutoBtn.classList.toggle('text-gray-700', manual); focusAutoBtn.classList.toggle('dark:text-gray-200', manual); } focusAutoBtn.addEventListener('click', async () => { if (!supportsAuto) return; try { await track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }); focusControl.classList.add('hidden'); setActiveButton(false); } catch (err) { focusUnsupported.textContent = 'Unable to switch to autofocus: ' + err.message; focusUnsupported.classList.remove('hidden'); } }); focusManualBtn.addEventListener('click', async () => { try { await track.applyConstraints({ advanced: [{ focusMode: 'manual', focusDistance: parseFloat(focusControl.value) }] }); focusControl.classList.remove('hidden'); setActiveButton(true); } catch (err) { focusUnsupported.textContent = 'Unable to switch to manual focus: ' + err.message; focusUnsupported.classList.remove('hidden'); } }); focusControl.addEventListener('input', async () => { try { await track.applyConstraints({ advanced: [{ focusMode: 'manual', focusDistance: parseFloat(focusControl.value) }] }); } catch (err) { /* ignore live-drag failures; the constraint is retried on the next input event */ } }); } function sanitizeFilename(name) { return name.trim().replace(/[^a-z0-9_-]+/gi, '_'); } function showLiveView() { previewImg.classList.add('hidden'); previewCanvas.classList.remove('hidden'); downloadLink.classList.add('hidden'); captureBtn.textContent = 'Capture Photo'; captured = false; } function showCapturedView() { previewCanvas.classList.add('hidden'); previewImg.classList.remove('hidden'); captureBtn.textContent = 'Retake Photo'; captured = true; } function capturePhoto() { if (captured) { showLiveView(); return; } const studentNumber = studentNumberInput.value.trim(); if (!studentNumber) { inputError.classList.remove('hidden'); studentNumberInput.focus(); return; } inputError.classList.add('hidden'); const { sx, sy, sw, sh } = currentCropRect(); captureCanvas.width = OUTPUT_WIDTH; captureCanvas.height = OUTPUT_HEIGHT; const ctx = captureCanvas.getContext('2d'); ctx.drawImage(video, sx, sy, sw, sh, 0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT); const dataUrl = captureCanvas.toDataURL('image/png'); const filename = `${sanitizeFilename(studentNumber)}.png`; previewImg.src = dataUrl; downloadLink.href = dataUrl; downloadLink.download = filename; downloadLink.classList.remove('hidden'); showCapturedView(); downloadLink.click(); } zoomControl.addEventListener('input', () => { zoomValue.textContent = `${parseFloat(zoomControl.value).toFixed(2)}x`; }); captureBtn.addEventListener('click', capturePhoto); studentNumberInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); capturePhoto(); } }); startCamera();