diff --git a/css/style.css b/css/style.css index 83f0de7..b2e722c 100644 --- a/css/style.css +++ b/css/style.css @@ -3,7 +3,3 @@ #camera-status { pointer-events: none; } - -video#video { - transform: scaleX(-1); /* mirror the preview so it feels like a mirror, not a rear camera */ -} diff --git a/index.html b/index.html index f905ec0..008e30f 100644 --- a/index.html +++ b/index.html @@ -41,14 +41,51 @@
- - + + +

Requesting camera access…

Frame matches the final 145×185 photo — fill it with the student's face and shoulders.

- + + +
+
+
+ + 1.5x +
+ +
+
+
+ + Left / Center / Right +
+ +
+
+
+ + Top / Center / Bottom +
+ +
+
+ + +
diff --git a/js/app.js b/js/app.js index 79fd1c5..d801d12 100644 --- a/js/app.js +++ b/js/app.js @@ -1,43 +1,181 @@ const video = document.getElementById('video'); +const previewCanvas = document.getElementById('preview-canvas'); const previewImg = document.getElementById('preview-img'); -const canvas = document.getElementById('canvas'); +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'); - video.classList.remove('hidden'); + previewCanvas.classList.remove('hidden'); downloadLink.classList.add('hidden'); captureBtn.textContent = 'Capture Photo'; captured = false; } function showCapturedView() { - video.classList.add('hidden'); + previewCanvas.classList.add('hidden'); previewImg.classList.remove('hidden'); captureBtn.textContent = 'Retake Photo'; captured = true; @@ -57,29 +195,14 @@ function capturePhoto() { } inputError.classList.add('hidden'); - const videoWidth = video.videoWidth; - const videoHeight = video.videoHeight; - const videoRatio = videoWidth / videoHeight; + const { sx, sy, sw, sh } = currentCropRect(); - let sx, sy, sw, sh; - if (videoRatio > TARGET_RATIO) { - sh = videoHeight; - sw = sh * TARGET_RATIO; - sx = (videoWidth - sw) / 2; - sy = 0; - } else { - sw = videoWidth; - sh = sw / TARGET_RATIO; - sx = 0; - sy = (videoHeight - sh) / 2; - } - - canvas.width = OUTPUT_WIDTH; - canvas.height = OUTPUT_HEIGHT; - const ctx = canvas.getContext('2d'); + 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 = canvas.toDataURL('image/png'); + const dataUrl = captureCanvas.toDataURL('image/png'); const filename = `${sanitizeFilename(studentNumber)}.png`; previewImg.src = dataUrl; @@ -92,6 +215,10 @@ function capturePhoto() { downloadLink.click(); } +zoomControl.addEventListener('input', () => { + zoomValue.textContent = `${parseFloat(zoomControl.value).toFixed(2)}x`; +}); + captureBtn.addEventListener('click', capturePhoto); studentNumberInput.addEventListener('keydown', (event) => {