232 lines
7.5 KiB
JavaScript
232 lines
7.5 KiB
JavaScript
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();
|