From f090c3f95e8d6a29bfdaa5995d2a55d5736fa8e8 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Sat, 10 Oct 2026 11:38:58 -0400 Subject: [PATCH 1/2] fix(export): write complete, valid EXIF for RAW sources RAW exports took their EXIF from RapidRAW's metadata cache, which holds display strings, and only read the RAW's typed values when that cache was missing. Capture dates were written as "2026-09-12 13:41:33", which is not valid EXIF, so Explorer and Immich showed no date taken, and white balance, flash, metering mode, exposure program and exposure bias were dropped (#1808, #1612). Always read the typed EXIF from the RAW first, also writing shutter speed, aperture value and lens specification, then let the cache override the fields it carries, with its dates converted to EXIF format. Add the ExifVersion, FlashpixVersion and (for JPEG) ComponentsConfiguration tags the spec requires. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/exif_processing.rs | 196 ++++++++++++++++++------------- 1 file changed, 113 insertions(+), 83 deletions(-) diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index cea2ba4d2c..86774b709f 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -289,11 +289,17 @@ fn clean_creation_datetime_str(s: &str) -> &str { s.trim().trim_matches('"').trim_matches('\'').trim() } -fn fmt_date_str(s: String) -> String { - if let Some(dt) = parse_creation_datetime(&s) { - return dt.format("%Y-%m-%d %H:%M:%S").to_string(); +const EXIF_DATETIME_FORMAT: &str = "%Y:%m:%d %H:%M:%S"; + +fn format_creation_datetime(s: &str, format: &str) -> String { + if let Some(dt) = parse_creation_datetime(s) { + return dt.format(format).to_string(); } - clean_creation_datetime_str(&s).to_string() + clean_creation_datetime_str(s).to_string() +} + +fn fmt_date_str(s: String) -> String { + format_creation_datetime(&s, "%Y-%m-%d %H:%M:%S") } fn normalize_creation_datetime(s: &str) -> Option { @@ -1090,6 +1096,92 @@ fn apply_sidecar_field_overrides(metadata: &mut Metadata, map: &HashMap String { match &field.value { exif::Value::Ascii(vec) => vec @@ -1458,81 +1558,6 @@ pub fn write_image_with_metadata( } } - if !source_read_success && is_raw_file(original_path_str) { - let loader = rawler::RawLoader::new(); - if let Ok(raw_source) = rawler::rawsource::RawSource::new(Path::new(original_path_str)) - && let Ok(decoder) = loader.get_decoder(&raw_source) - && let Ok(meta) = decoder.raw_metadata(&raw_source, &Default::default()) - { - if !meta.make.is_empty() { - metadata.set_tag(ExifTag::Make(meta.make.clone())); - } - if !meta.model.is_empty() { - metadata.set_tag(ExifTag::Model(meta.model.clone())); - } - let exif = meta.exif; - if let Some(artist) = exif.artist { - metadata.set_tag(ExifTag::Artist(artist)); - } - if let Some(copyright) = exif.copyright { - metadata.set_tag(ExifTag::Copyright(copyright)); - } - if let Some(dt) = exif.date_time_original { - metadata.set_tag(ExifTag::DateTimeOriginal(dt)); - } - if let Some(dt) = exif.create_date { - metadata.set_tag(ExifTag::CreateDate(dt)); - } - if let Some(lens_make) = exif.lens_make { - metadata.set_tag(ExifTag::LensMake(lens_make)); - } - if let Some(lens_model) = exif.lens_model { - metadata.set_tag(ExifTag::LensModel(lens_model)); - } - if let Some(f) = exif.fnumber { - metadata.set_tag(ExifTag::FNumber(vec![uR64 { - nominator: f.n, - denominator: f.d, - }])); - } - if let Some(t) = exif.exposure_time { - metadata.set_tag(ExifTag::ExposureTime(vec![uR64 { - nominator: t.n, - denominator: t.d, - }])); - } - if let Some(fl) = exif.focal_length { - metadata.set_tag(ExifTag::FocalLength(vec![uR64 { - nominator: fl.n, - denominator: fl.d, - }])); - } - if let Some(iso) = exif.iso_speed { - metadata.set_tag(ExifTag::ISO(vec![iso as u16])); - } else if let Some(iso) = exif.iso_speed_ratings { - metadata.set_tag(ExifTag::ISO(vec![iso])); - } - if let Some(ev) = exif.exposure_bias { - metadata.set_tag(ExifTag::ExposureCompensation(vec![iR64 { - nominator: ev.n, - denominator: ev.d, - }])); - } - if let Some(flash) = exif.flash { - metadata.set_tag(ExifTag::Flash(vec![flash])); - } - if let Some(metering) = exif.metering_mode { - metadata.set_tag(ExifTag::MeteringMode(vec![metering])); - } - if let Some(wb) = exif.white_balance { - metadata.set_tag(ExifTag::WhiteBalance(vec![wb])); - } - if let Some(prog) = exif.exposure_program { - metadata.set_tag(ExifTag::ExposureProgram(vec![prog])); - } - } - } - if !strip_gps { if is_raw_file(original_path_str) { apply_gps_from_rawler(&mut metadata, original_path_str); @@ -1548,6 +1573,11 @@ pub fn write_image_with_metadata( metadata.set_tag(ExifTag::Software("RapidRAW".to_string())); metadata.set_tag(ExifTag::Orientation(vec![1u16])); metadata.set_tag(ExifTag::ColorSpace(vec![1u16])); + metadata.set_tag(ExifTag::ExifVersion(b"0232".to_vec())); + metadata.set_tag(ExifTag::FlashpixVersion(b"0100".to_vec())); + if matches!(file_type, FileExtension::JPEG) { + metadata.set_tag(ExifTag::ComponentsConfiguration(vec![1, 2, 3, 0])); + } if let Ok(reader) = image::ImageReader::new(Cursor::new(image_bytes.as_slice())).with_guessed_format() From 58146486aa6c4b04908a8032664761b306ec678c Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Sun, 11 Oct 2026 01:13:34 -0400 Subject: [PATCH 2/2] refactor(export): read RAW metadata once for EXIF and GPS Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/exif_processing.rs | 153 ++++++++++++------------------- 1 file changed, 60 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 86774b709f..90f9aba586 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -15,6 +15,7 @@ use little_exif::ifd::ExifTagGroup; use little_exif::metadata::Metadata; use little_exif::rational::{iR64, uR64}; use rawler::decoders::RawMetadata; +use rawler::exif::ExifGPS; use rawler::lens::LensDescription; use serde::{Deserialize, Serialize}; @@ -285,6 +286,20 @@ fn to_ir64(val: &exif::SRational) -> iR64 { } } +fn rawler_to_ur64(val: rawler::formats::tiff::Rational) -> uR64 { + uR64 { + nominator: val.n, + denominator: val.d, + } +} + +fn rawler_to_ir64(val: rawler::formats::tiff::SRational) -> iR64 { + iR64 { + nominator: val.n, + denominator: val.d, + } +} + fn clean_creation_datetime_str(s: &str) -> &str { s.trim().trim_matches('"').trim_matches('\'').trim() } @@ -424,10 +439,17 @@ pub fn read_exif(file_bytes: &[u8]) -> Option { } pub fn read_raw_metadata(file_bytes: &[u8]) -> Option { + raw_metadata_from_source(&rawler::rawsource::RawSource::new_from_slice(file_bytes)) +} + +fn read_raw_metadata_from_path(path_str: &str) -> Option { + raw_metadata_from_source(&rawler::rawsource::RawSource::new(Path::new(path_str)).ok()?) +} + +fn raw_metadata_from_source(raw_source: &rawler::rawsource::RawSource) -> Option { let loader = rawler::RawLoader::new(); - let raw_source = rawler::rawsource::RawSource::new_from_slice(file_bytes); - let decoder = loader.get_decoder(&raw_source).ok()?; - decoder.raw_metadata(&raw_source, &Default::default()).ok() + let decoder = loader.get_decoder(raw_source).ok()?; + decoder.raw_metadata(raw_source, &Default::default()).ok() } fn insert_missing_lens_fields(map: &mut HashMap, lens: &LensDescription) { @@ -1096,69 +1118,49 @@ fn apply_sidecar_field_overrides(metadata: &mut Metadata, map: &HashMap