WIP: EXIF metadata on export
Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here.
This commit is contained in:
@@ -21,8 +21,8 @@ pub mod profile;
|
||||
pub use base_curve::BaseCurve;
|
||||
pub use error::DecodeError;
|
||||
pub use locate::{
|
||||
defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation,
|
||||
HEADER_BYTES,
|
||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||
Defects, PreviewLocation, HEADER_BYTES,
|
||||
};
|
||||
pub use preview::{
|
||||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||||
@@ -30,7 +30,7 @@ pub use preview::{
|
||||
};
|
||||
pub use profile::CameraProfile;
|
||||
|
||||
use dr_types::{Format, Orientation};
|
||||
use dr_types::{Format, Location, Orientation};
|
||||
|
||||
/// Capture metadata read from a file header.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
@@ -68,6 +68,27 @@ pub struct Metadata {
|
||||
/// to where it was taken, so without this a shoot in Tokyo displays on the
|
||||
/// wrong day in Paris.
|
||||
pub captured_offset: Option<i32>,
|
||||
/// TRACES: FR-EXP-8
|
||||
/// Who made the photograph (EXIF `Artist`, 0x013B).
|
||||
///
|
||||
/// Read for the sake of exporting it again: a photographer who set a byline
|
||||
/// in-camera set it so that it would still be there in the copy they hand
|
||||
/// over, and an export that dropped it would be quietly removing the one
|
||||
/// piece of metadata that says whose work this is.
|
||||
pub artist: Option<String>,
|
||||
/// TRACES: FR-EXP-8
|
||||
/// The rights statement (EXIF `Copyright`, 0x8298).
|
||||
pub copyright: Option<String>,
|
||||
/// TRACES: FR-EXP-8
|
||||
/// Where the shutter fired (the EXIF GPS directory, 0x8825).
|
||||
///
|
||||
/// **Read, but treated as radioactive downstream.** This is the field
|
||||
/// FR-EXP-8's strip option exists for, and the export path drops it unless
|
||||
/// the user has explicitly said otherwise (`ExportSettings::strip_location`
|
||||
/// defaults to on). Parsing it here rather than refusing to look is what
|
||||
/// makes "keep my coordinates" possible at all, and what lets the exporter
|
||||
/// prove the field is gone rather than hope it was never present.
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
/// Decoded sensor data, before demosaic.
|
||||
@@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
.as_deref()
|
||||
.or(exif.offset_time.as_deref())
|
||||
.and_then(parse_exif_offset),
|
||||
// TRACES: FR-EXP-8
|
||||
artist: exif.artist.clone().filter(|s| !s.trim().is_empty()),
|
||||
copyright: exif.copyright.clone().filter(|s| !s.trim().is_empty()),
|
||||
location: exif.gps.as_ref().and_then(rawler_location),
|
||||
};
|
||||
|
||||
// rawler reports no capture time for some TIFF-derived files whose tag is
|
||||
@@ -322,12 +347,57 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
out.iso = out.iso.or(fallback.iso);
|
||||
out.lens = out.lens.take().or(fallback.lens);
|
||||
out.orientation = out.orientation.or(fallback.orientation);
|
||||
// TRACES: FR-EXP-8
|
||||
// Filled from the same walk for the same reason: a file rawler
|
||||
// answered short on is one whose byline and rights statement would
|
||||
// otherwise be dropped at export, and both sit in the IFD this has
|
||||
// already read.
|
||||
out.artist = out.artist.take().or(fallback.artist);
|
||||
out.copyright = out.copyright.take().or(fallback.copyright);
|
||||
out.location = out.location.or(fallback.location);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// rawler's GPS directory as a position.
|
||||
///
|
||||
/// The three-rational form is the tag's, not a position's: degrees, minutes
|
||||
/// and seconds, each a fraction, with the hemisphere in a separate letter.
|
||||
/// Everything downstream wants a number it can compare and write back, so the
|
||||
/// conversion happens once, here.
|
||||
fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
|
||||
/// A `Rational` as a float, with a zero denominator refused rather than
|
||||
/// divided by — some bodies write `0/0` into an unfilled slot.
|
||||
fn ratio(r: &rawler::formats::tiff::Rational) -> Option<f64> {
|
||||
(r.d != 0).then(|| r.n as f64 / r.d as f64)
|
||||
}
|
||||
|
||||
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> {
|
||||
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
|
||||
// South and west are stored as positive magnitudes with a letter.
|
||||
let negative = matches!(
|
||||
reference.map(|s| s.trim().to_ascii_uppercase()).as_deref(),
|
||||
Some("S") | Some("W")
|
||||
);
|
||||
Some(if negative { -d } else { d })
|
||||
}
|
||||
|
||||
let latitude = degrees(gps.gps_latitude.as_ref()?, gps.gps_latitude_ref.as_ref())?;
|
||||
let longitude = degrees(gps.gps_longitude.as_ref()?, gps.gps_longitude_ref.as_ref())?;
|
||||
// Reference 1 means below sea level; the altitude itself is unsigned.
|
||||
let altitude = gps.gps_altitude.as_ref().and_then(ratio).map(|a| {
|
||||
if gps.gps_altitude_ref == Some(1) {
|
||||
-a
|
||||
} else {
|
||||
a
|
||||
}
|
||||
});
|
||||
Location::new(latitude, longitude, altitude)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | FR-DEV-3h
|
||||
/// Read just the stored orientation, from a file header.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user