EXIF GPS write: POST /image/exif/gps via exiftool

New endpoint accepts {path, library, latitude, longitude} and shells
out to exiftool to write GPSLatitude/GPSLongitude (with N/S, E/W refs)
into the file's EXIF in place. After the write, the handler
re-extracts EXIF and updates the image_exif row so the DB stays in
sync — the response carries the updated metadata block in one
round-trip. Falls through to store_exif if the row is missing.

`exif::write_gps` is the small helper. `-overwrite_original` so no
.orig sidecar is left behind. Validates lat/lon range + supports_exif
before spawning exiftool. Format support matches the existing read
path (JPEG / TIFF / RAW / HEIF / PNG / WebP) — videos still need a
different writer and aren't covered.

Apollo's "+ PIN" carousel button (separate commit on the Apollo side)
calls this through /api/photos/exif/gps. Drive-by: cargo fmt one-line
collapse on apollo_client.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-28 22:25:40 +00:00
parent 4ae7be35e9
commit 57fb0bcd3c
5 changed files with 217 additions and 6 deletions

View File

@@ -160,6 +160,52 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option<Vec<u8>> {
.max_by_key(|v| v.len())
}
/// Write GPS lat/lon into the file's EXIF in place via exiftool. Touches
/// nothing else — camera, dates, MakerNote, etc. all stay as-is. Uses
/// `-overwrite_original` so no `.orig` sidecar is left behind (the
/// caller's responsibility to back up the file system if they want
/// rollback). Returns Err if exiftool isn't on PATH, the file format
/// doesn't support EXIF, lat/lon are out of range, or exiftool prints
/// to stderr.
///
/// We pass lat/lon as positive decimal numbers and let the *Ref tags
/// carry the sign (N/S, E/W). exiftool happily accepts signed decimals
/// too, but the explicit ref form is unambiguous across exiftool
/// versions and matches what cameras write.
pub fn write_gps(path: &Path, lat: f64, lon: f64) -> Result<()> {
if !supports_exif(path) {
return Err(anyhow!(
"Format does not support EXIF GPS write: {}",
path.display()
));
}
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
return Err(anyhow!("GPS coordinates out of range: {}, {}", lat, lon));
}
let lat_ref = if lat >= 0.0 { "N" } else { "S" };
let lon_ref = if lon >= 0.0 { "E" } else { "W" };
let lat_abs = lat.abs();
let lon_abs = lon.abs();
let output = Command::new("exiftool")
.arg("-overwrite_original")
.arg(format!("-GPSLatitude={}", lat_abs))
.arg(format!("-GPSLatitudeRef={}", lat_ref))
.arg(format!("-GPSLongitude={}", lon_abs))
.arg(format!("-GPSLongitudeRef={}", lon_ref))
.arg(path)
.output()
.map_err(|e| anyhow!("exiftool spawn failed (is it on PATH?): {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"exiftool failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(())
}
pub fn supports_exif(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_lower = ext.to_string_lossy().to_lowercase();