Add ability to adjust framing, zoom, and focus
This commit is contained in:
parent
1bed329472
commit
105ec19bf1
@ -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 */
|
||||
}
|
||||
|
||||
43
index.html
43
index.html
@ -41,14 +41,51 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="relative bg-black rounded-xl overflow-hidden aspect-[145/185] max-w-[280px] mx-auto flex items-center justify-center">
|
||||
<video id="video" class="w-full h-full object-cover" autoplay playsinline muted></video>
|
||||
<img id="preview-img" class="hidden w-full h-full object-cover" alt="Captured student photo">
|
||||
<video id="video" class="absolute inset-0 w-full h-full object-cover opacity-0 pointer-events-none" autoplay playsinline muted></video>
|
||||
<canvas id="preview-canvas" class="w-full h-full"></canvas>
|
||||
<img id="preview-img" class="hidden absolute inset-0 w-full h-full object-cover" alt="Captured student photo">
|
||||
<p id="camera-status" class="absolute text-gray-300 text-sm px-4 text-center">Requesting camera access…</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 text-center">Frame matches the final 145×185 photo — fill it with the student's face and shoulders.</p>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" class="hidden"></canvas>
|
||||
<canvas id="capture-canvas" class="hidden"></canvas>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<label for="zoom-control">Zoom</label>
|
||||
<span id="zoom-value">1.5x</span>
|
||||
</div>
|
||||
<input id="zoom-control" type="range" min="1" max="3" step="0.05" value="1.5" class="w-full accent-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<label for="pan-x-control">Horizontal Position</label>
|
||||
<span>Left / Center / Right</span>
|
||||
</div>
|
||||
<input id="pan-x-control" type="range" min="-1" max="1" step="0.01" value="0" class="w-full accent-blue-600">
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<label for="pan-y-control">Vertical Position</label>
|
||||
<span>Top / Center / Bottom</span>
|
||||
</div>
|
||||
<input id="pan-y-control" type="range" min="-1" max="1" step="0.01" value="0" class="w-full accent-blue-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="focus-controls" class="hidden space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Camera Focus</span>
|
||||
<div class="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600 text-xs">
|
||||
<button id="focus-auto-btn" type="button" class="px-3 py-1 bg-blue-600 text-white">Auto</button>
|
||||
<button id="focus-manual-btn" type="button" class="px-3 py-1 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200">Manual</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="focus-control" type="range" class="hidden w-full accent-blue-600" min="0" max="1" step="0.01" value="0">
|
||||
</div>
|
||||
<p id="focus-unsupported" class="hidden text-xs text-gray-400 dark:text-gray-500 text-center">This camera or browser doesn't expose manual focus control — it will use autofocus.</p>
|
||||
|
||||
<div>
|
||||
<label for="student-number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Student Number</label>
|
||||
|
||||
173
js/app.js
173
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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user