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 @@
-
-
![Captured student photo]()
+
+
+
Requesting camera access…
Frame matches the final 145×185 photo — fill it with the student's face and shoulders.
-
+
+
+
+
+
+
+
+ Left / Center / Right
+
+
+
+
+
+
+ Top / Center / Bottom
+
+
+
+
+
+
+
+
Camera Focus
+
+
+
+
+
+
+
+ This camera or browser doesn't expose manual focus control — it will use autofocus.
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) => {