Files
nextsnap/app/templates/capture.html
kamaji c4a6239f46 Improve JPEG output quality
- Live camera: 0.92 -> 0.97 quality
- Library upload: 0.92 -> 0.97 starting quality
- Size limit: 10MB -> 20MB before compression kicks in
- Gentler quality steps: drops by 0.03 instead of 0.1
- Higher quality floor: 0.50 instead of 0.30
- Gentler downscaling: 0.85x instead of 0.8x per step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 05:03:23 -06:00

647 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}Capture - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="capture-section">
<p class="upload-path">
Uploading to: <strong><span id="current-path">/</span></strong>
<a href="/browser" class="change-link">Change</a>
</p>
<button class="btn btn-primary btn-capture" id="capture-btn">
📷 Take Photo
</button>
<button class="btn btn-secondary btn-upload" id="upload-btn">
🖼️ Upload from Library
</button>
<!-- Hidden file input as fallback for camera -->
<input
type="file"
id="camera-input"
accept="image/*"
capture="environment"
style="display: none;"
>
<!-- Hidden file input for photo library (multiple) -->
<input
type="file"
id="upload-input"
accept="image/*"
multiple
style="display: none;"
>
</div>
<div class="recent-photos" id="recent-photos">
<h3>Recent Photos</h3>
<div class="photo-thumbnails" id="photo-thumbnails">
<p class="empty-state">No photos captured yet</p>
</div>
</div>
</div>
<!-- Fullscreen Camera Viewfinder -->
<div class="camera-viewfinder" id="camera-viewfinder" style="display:none">
<video id="camera-video" autoplay playsinline muted></video>
<!-- Flash overlay for shutter feedback -->
<div class="camera-flash" id="camera-flash"></div>
<div class="camera-top-bar">
<button class="camera-ui-btn camera-close-btn" id="camera-close-btn">&times;</button>
<span class="camera-counter" id="camera-counter"></span>
<div style="width:48px"></div>
</div>
<div class="camera-bottom-bar">
<div style="width:48px"></div>
<button class="camera-shutter-btn" id="camera-shutter-btn">
<span class="shutter-ring"></span>
</button>
<button class="camera-ui-btn camera-flip-btn" id="camera-flip-btn">🔄</button>
</div>
<!-- Hidden canvas for frame capture -->
<canvas id="camera-canvas" style="display:none"></canvas>
<!-- Toast inside viewfinder -->
<div class="camera-toast" id="camera-toast"></div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.capture-section {
text-align: center;
margin: 2rem 0;
}
.upload-path {
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.upload-path strong {
color: var(--text-primary);
}
.change-link {
color: var(--accent);
margin-left: 0.5rem;
text-decoration: none;
}
.change-link:hover {
text-decoration: underline;
}
.btn-capture {
font-size: 1.25rem;
padding: 1.5rem;
margin: 1rem 0;
}
.btn-capture:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-upload {
font-size: 1.1rem;
padding: 1rem;
margin: 0.5rem 0;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--bg-tertiary);
}
.btn-upload:disabled {
opacity: 0.6;
}
.recent-photos {
margin-top: 3rem;
padding-bottom: 2rem;
}
.recent-photos h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.photo-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.photo-thumbnails .empty-state {
grid-column: 1 / -1;
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem;
font-size: 0.9rem;
}
.thumbnail {
aspect-ratio: 1;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail .status-badge {
position: absolute;
top: 4px;
right: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-primary);
}
.thumbnail .status-badge.pending {
background: var(--warning);
}
.thumbnail .status-badge.uploading {
background: var(--accent);
animation: pulse 1s infinite;
}
.thumbnail .status-badge.verified {
background: var(--success);
}
/* Camera Viewfinder */
body.camera-open {
overflow: hidden;
}
.camera-viewfinder {
position: fixed;
inset: 0;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.camera-viewfinder video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-flash {
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
pointer-events: none;
z-index: 2;
transition: opacity 0.05s;
}
.camera-flash.flash {
opacity: 0.6;
}
.camera-top-bar {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
padding-top: max(0.5rem, env(safe-area-inset-top));
background: rgba(0,0,0,0.4);
}
.camera-ui-btn {
background: none;
border: none;
color: #fff;
font-size: 1.5rem;
padding: 0.5rem;
min-width: 48px;
min-height: 48px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.camera-close-btn {
font-size: 2.2rem;
line-height: 1;
}
.camera-counter {
color: #fff;
font-size: 1rem;
font-weight: 600;
}
.camera-bottom-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
background: rgba(0,0,0,0.4);
}
.camera-shutter-btn {
width: 72px;
height: 72px;
border-radius: 50%;
border: 4px solid #fff;
background: rgba(255,255,255,0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
.camera-shutter-btn:active {
transform: scale(0.92);
}
.camera-shutter-btn:disabled {
opacity: 0.4;
}
.shutter-ring {
display: block;
width: 56px;
height: 56px;
border-radius: 50%;
background: #fff;
}
.camera-flip-btn {
font-size: 1.5rem;
}
.camera-toast {
position: absolute;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem 1.25rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 4;
display: none;
white-space: nowrap;
background: rgba(40, 167, 69, 0.85);
color: #fff;
pointer-events: none;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
function updateUploadPath() {
const savedPath = localStorage.getItem('nextsnap_upload_path') || '/';
document.getElementById('current-path').textContent = savedPath;
}
updateUploadPath();
let storageReady = false;
Storage.init().then(() => {
storageReady = true;
SyncEngine.init(currentUsername);
loadRecentPhotos();
}).catch(err => console.error('Storage init failed:', err));
const captureBtn = document.getElementById('capture-btn');
const cameraInput = document.getElementById('camera-input');
// ---- Camera state ----
let cameraStream = null;
let facingMode = 'environment';
let sessionCount = 0;
let shutterBusy = false;
// ---- Main button: open live viewfinder, fallback to file input ----
captureBtn.addEventListener('click', async function() {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
await openCamera();
} else {
// Fallback: use file input
cameraInput.click();
}
});
// ---- File input fallback (single-shot) ----
cameraInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
captureBtn.disabled = true;
captureBtn.textContent = '⏳ Processing...';
try {
const jpegBlob = await convertToJPEG(file);
await savePhoto(jpegBlob);
captureBtn.textContent = '✓ Photo Saved!';
loadRecentPhotos();
setTimeout(() => {
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}, 1000);
} catch (error) {
console.error('Error:', error);
alert('Failed: ' + error.message);
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}
e.target.value = '';
});
// ---- Upload from library ----
const uploadBtn = document.getElementById('upload-btn');
const uploadInput = document.getElementById('upload-input');
uploadBtn.addEventListener('click', function() {
uploadInput.click();
});
uploadInput.addEventListener('change', async function(e) {
const files = Array.from(e.target.files);
if (files.length === 0) return;
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Processing 0/' + files.length + '...';
let saved = 0;
for (const file of files) {
try {
const jpegBlob = await convertToJPEG(file);
await savePhoto(jpegBlob);
saved++;
uploadBtn.textContent = '⏳ Processing ' + saved + '/' + files.length + '...';
} catch (err) {
console.error('Failed to process file:', file.name, err);
}
}
uploadBtn.textContent = '✓ ' + saved + ' photo' + (saved !== 1 ? 's' : '') + ' added';
loadRecentPhotos();
setTimeout(() => {
uploadBtn.disabled = false;
uploadBtn.textContent = '🖼️ Upload from Library';
}, 1500);
e.target.value = '';
});
// ---- Live camera viewfinder ----
async function openCamera() {
sessionCount = 0;
updateCameraCounter();
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } },
audio: false
});
const video = document.getElementById('camera-video');
video.srcObject = cameraStream;
await video.play();
document.getElementById('camera-viewfinder').style.display = 'flex';
document.body.classList.add('camera-open');
} catch (err) {
console.error('getUserMedia failed:', err);
// Fallback to file input
cameraInput.click();
}
}
function closeCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(t => t.stop());
cameraStream = null;
}
const video = document.getElementById('camera-video');
video.srcObject = null;
document.getElementById('camera-viewfinder').style.display = 'none';
document.body.classList.remove('camera-open');
loadRecentPhotos();
}
async function flipCamera() {
facingMode = facingMode === 'environment' ? 'user' : 'environment';
if (cameraStream) {
cameraStream.getTracks().forEach(t => t.stop());
}
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } },
audio: false
});
const video = document.getElementById('camera-video');
video.srcObject = cameraStream;
await video.play();
} catch (err) {
console.error('Flip camera failed:', err);
showCameraToast('Could not switch camera');
}
}
async function takePhoto() {
if (shutterBusy) return;
shutterBusy = true;
const shutterBtn = document.getElementById('camera-shutter-btn');
shutterBtn.disabled = true;
try {
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Flash feedback
const flash = document.getElementById('camera-flash');
flash.classList.add('flash');
setTimeout(() => flash.classList.remove('flash'), 150);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas capture failed')), 'image/jpeg', 0.97);
});
await savePhoto(blob);
sessionCount++;
updateCameraCounter();
showCameraToast('Photo saved');
} catch (err) {
console.error('takePhoto error:', err);
showCameraToast('Error saving photo');
} finally {
shutterBusy = false;
shutterBtn.disabled = false;
}
}
// ---- Wire viewfinder buttons ----
document.getElementById('camera-close-btn').addEventListener('click', closeCamera);
document.getElementById('camera-shutter-btn').addEventListener('click', takePhoto);
document.getElementById('camera-flip-btn').addEventListener('click', flipCamera);
function updateCameraCounter() {
const el = document.getElementById('camera-counter');
el.textContent = sessionCount > 0 ? sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') : '';
}
function showCameraToast(msg) {
const toast = document.getElementById('camera-toast');
toast.textContent = msg;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 1500);
}
// ---- Shared helpers ----
async function savePhoto(jpegBlob) {
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') + '_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const filename = currentUsername + '_' + timestamp + '.jpg';
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
if (storageReady) {
await Storage.savePhoto({
username: currentUsername,
timestamp: Date.now(),
filename: filename,
targetPath: targetPath,
blob: jpegBlob,
status: 'pending'
});
if (navigator.onLine && typeof SyncEngine !== 'undefined') {
SyncEngine.triggerSync();
}
}
}
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
function convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let quality = 0.97;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to convert'));
return;
}
if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.5) {
resolve(blob);
} else {
if (quality > 0.85) {
quality -= 0.03;
} else {
scale *= 0.85;
quality = 0.92;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => reject(new Error('Failed to load'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read'));
reader.readAsDataURL(file);
});
}
async function loadRecentPhotos() {
if (!storageReady) return;
const container = document.getElementById('photo-thumbnails');
const photos = await Storage.getRecentPhotos(currentUsername, 5);
if (photos.length === 0) {
container.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
container.innerHTML = '';
for (const photo of photos) {
const url = URL.createObjectURL(photo.blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'uploading' ? 'uploading' : 'pending';
const div = document.createElement('div');
div.className = 'thumbnail';
div.innerHTML = '<img src="' + url + '"><span class="status-badge ' + statusClass + '"></span>';
container.appendChild(div);
}
}
console.log('Capture page loaded');
</script>
{% endblock %}