fix: EXIF GPS extraction now works with native camera capture

- Replaced getUserMedia() + canvas.toBlob() with <input type=file capture=environment>
  → native camera app preserves full EXIF data including GPS
- Defined missing extractGpsFromPhoto() using exifr.gps() library
- Manual mode now uploads the original file (with EXIF) instead of a canvas-encoded blob
- Server-side exif_gps fallback if client-side extraction fails
- Renamed manualPhotoBlob → manualPhotoFile for clarity
This commit is contained in:
2026-05-21 20:51:09 -04:00
parent 62146c5ce7
commit 906b61143a
+49 -97
View File
@@ -983,14 +983,14 @@
<div class="form-section">
<div class="form-section-title">📸 Photo (optional)</div>
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="document.getElementById('manPhotoInput').click()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to take photo</div>
</div>
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
<input type="file" id="manPhotoInput" accept="image/*" capture="environment" style="display:none;" onchange="handleManualPhotoFile(event)">
</div>
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
<button class="ocr-capture-btn" onclick="captureManualPhoto()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
<button class="ocr-capture-btn" onclick="document.getElementById('manPhotoInput').click()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
</div>
<img id="manPhotoPreview" style="display:none;width:100%;border-radius:var(--radius-sm);">
<button id="manPhotoRetake" class="btn btn-outline btn-sm" style="display:none;margin-top:6px;width:100%;" onclick="retakeManualPhoto()">🔄 Retake</button>
@@ -2466,62 +2466,47 @@
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Photo
// ═══════════════════════════════════════════════════════════════════════
let manualPhotoStream = null;
let manualPhotoFile = null;
let manualPhotoBlob = null;
let manualPhotoGps = null;
async function startManualPhoto() {
if (manualPhotoStream) return;
try {
manualPhotoStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
const video = document.getElementById('manPhotoVideo');
if (!video) return;
video.srcObject = manualPhotoStream;
await video.play();
document.getElementById('manPhotoPlaceholder').style.display = 'none';
video.style.display = 'block';
document.getElementById('manPhotoCaptureRow').style.display = 'flex';
} catch (e) {
showToast('Camera failed: ' + e.message, true);
}
}
async function handleManualPhotoFile(event) {
const file = event.target.files?.[0];
if (!file) return;
manualPhotoFile = file;
manualPhotoBlob = file; // Use original file (preserves EXIF)
async function captureManualPhoto() {
const video = document.getElementById('manPhotoVideo');
if (!video || !manualPhotoStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
manualPhotoBlob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
const url = URL.createObjectURL(manualPhotoBlob);
// Show preview
const url = URL.createObjectURL(file);
document.getElementById('manPhotoPreview').src = url;
document.getElementById('manPhotoPreview').style.display = 'block';
document.getElementById('manPhotoRetake').style.display = 'block';
stopManualPhoto();
document.getElementById('manPhotoPlaceholder').style.display = 'none';
// Try EXIF GPS from the original file
manualPhotoGps = await extractGpsFromPhoto(file);
if (manualPhotoGps) {
const coords = manualPhotoGps.lat.toFixed(6) + ', ' + manualPhotoGps.lng.toFixed(6);
const parkingEl = document.getElementById('manParkingLocation');
if (parkingEl && !parkingEl.value.trim()) {
parkingEl.value = coords;
}
const mapLinkEl = document.getElementById('manMapLink');
if (mapLinkEl && !mapLinkEl.value.trim()) {
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${manualPhotoGps.lat}&mlon=${manualPhotoGps.lng}&zoom=17`;
}
showToast('📍 GPS extracted from photo!');
}
}
function retakeManualPhoto() {
manualPhotoFile = null;
manualPhotoBlob = null;
manualPhotoGps = null;
document.getElementById('manPhotoPreview').style.display = 'none';
document.getElementById('manPhotoRetake').style.display = 'none';
startManualPhoto();
}
function stopManualPhoto() {
if (manualPhotoStream) {
manualPhotoStream.getTracks().forEach(t => t.stop());
manualPhotoStream = null;
}
const video = document.getElementById('manPhotoVideo');
if (video) video.style.display = 'none';
const ph = document.getElementById('manPhotoPlaceholder');
if (ph) ph.style.display = '';
const row = document.getElementById('manPhotoCaptureRow');
if (row) row.style.display = 'none';
document.getElementById('manPhotoPlaceholder').style.display = '';
document.getElementById('manPhotoInput').value = '';
}
// ═══════════════════════════════════════════════════════════════════════
@@ -2536,12 +2521,16 @@
}
let photoPath = null;
if (manualPhotoBlob) {
if (manualPhotoFile) {
try {
const fd = new FormData();
fd.append('file', manualPhotoBlob, 'photo.jpg');
fd.append('file', manualPhotoFile, manualPhotoFile.name || 'photo.jpg');
const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
photoPath = upData.path;
// If server returned EXIF GPS and we don't have GPS from the file yet, use it
if (upData.exif_gps && !manualPhotoGps) {
manualPhotoGps = upData.exif_gps;
}
} catch (e) { /* photo upload failed, continue without */ }
}
@@ -3780,55 +3769,18 @@
}
// ═══════════════════════════════════════════════════════════════════════
// HOOK: EXIF GPS extraction after camera capture in manual mode
// Extract GPS from a photo file using exifr library
// ═══════════════════════════════════════════════════════════════════════
// Patch captureManualPhoto to also try EXIF GPS extraction
const _origCaptureManualPhoto = captureManualPhoto;
captureManualPhoto = async function() {
await _origCaptureManualPhoto();
// After capture, try EXIF GPS from the blob
if (manualPhotoBlob) {
try {
const gps = await extractGpsFromPhoto(manualPhotoBlob);
if (gps) {
const coords = gps.lat.toFixed(6) + ', ' + gps.lng.toFixed(6);
const parkingEl = document.getElementById('manParkingLocation');
if (parkingEl && !parkingEl.value.trim()) {
parkingEl.value = coords;
}
const mapLinkEl = document.getElementById('manMapLink');
if (mapLinkEl && !mapLinkEl.value.trim()) {
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${gps.lat}&mlon=${gps.lng}&zoom=17`;
}
// Show GPS badge near photo
const photoSection = document.querySelector('#addManualMode .form-section');
let gpsTag = document.getElementById('manPhotoGpsTag');
if (!gpsTag) {
gpsTag = document.createElement('span');
gpsTag.id = 'manPhotoGpsTag';
gpsTag.className = 'gps-badge ok';
gpsTag.style.marginLeft = '8px';
const title = document.querySelector('#addManualMode .form-section-title');
if (title) title.appendChild(gpsTag);
}
gpsTag.textContent = '📍 GPS from photo';
gpsTag.style.display = 'inline-flex';
showToast('📍 GPS extracted from photo!');
}
} catch (e) {
// GPS extraction failure is non-critical
console.warn('EXIF GPS from capture failed:', e.message);
}
async function extractGpsFromPhoto(file) {
if (!file) return null;
try {
const gps = await exifr.gps(file);
return gps || null;
} catch (e) {
console.warn('EXIF GPS extraction failed:', e.message);
return null;
}
};
// Patch retakeManualPhoto to clear GPS tag
const _origRetakeManualPhoto = retakeManualPhoto;
retakeManualPhoto = function() {
_origRetakeManualPhoto();
const gpsTag = document.getElementById('manPhotoGpsTag');
if (gpsTag) gpsTag.style.display = 'none';
};
}
// Boot auth at page load
document.addEventListener('DOMContentLoaded', () => { initAuth(); });