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:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit cd8750462f
8 changed files with 1797 additions and 46 deletions
+29 -12
View File
@@ -26,12 +26,15 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
mod encode;
mod error;
mod exif;
pub mod icc;
mod metadata;
mod name;
mod sharpen;
mod size;
pub use error::ExportError;
pub use metadata::SourceMetadata;
pub use name::{resolve_name, NameContext};
pub use size::target_size;
@@ -129,10 +132,23 @@ pub struct Encoded {
/// resamples down from it. Exporting from the display proxy would silently
/// produce a soft file, which is why the develop session's export path renders
/// its own frame rather than reusing the one on screen.
///
/// TRACES: FR-EXP-8
/// `source` is what the photograph's own file said about itself, or `None`
/// where the caller has nothing — a frame that came from somewhere other than
/// a decoded file, or a caller that has not yet been taught to pass it.
///
/// **A parameter rather than a field on [`Frame`]**, because it is not a fact
/// about the pixels: two exports of the same frame can legitimately disclose
/// different amounts, and the settings that decide how much travel beside it.
/// It is also why this is an argument and not an `Option` with a default — a
/// caller that has the source metadata should have to decide, in one visible
/// place, to hand it over.
pub fn export(
frame: &Frame,
settings: &ExportSettings,
name: String,
source: Option<&SourceMetadata>,
) -> Result<Encoded, ExportError> {
// TRACES: FR-EXP-2
// Refused rather than mislabelled. Every space the settings page offers
@@ -170,7 +186,7 @@ pub fn export(
let scale = width as f32 / frame.width.max(1) as f32;
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
let bytes = encode::encode(&sharpened, width, height, settings)?;
let bytes = encode::encode(&sharpened, width, height, settings, source)?;
Ok(Encoded {
name,
@@ -223,6 +239,7 @@ mod tests {
&frame(64, 48),
&settings(ExportFormat::Jpeg),
"a.jpg".into(),
None,
)
.unwrap();
// SOI marker. Cheap, and it catches an encoder wired to the wrong
@@ -233,14 +250,14 @@ mod tests {
#[test]
fn png_export_produces_a_png() {
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into()).unwrap();
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn tiff_exports_produce_a_tiff() {
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
let out = export(&frame(16, 16), &settings(format), "a.tif".into()).unwrap();
let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap();
// Either byte order is a valid TIFF; the crate writes little-endian.
assert!(
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
@@ -253,8 +270,8 @@ mod tests {
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
// Both are uncompressed RGB; the only difference is the sample width,
// so this is what proves the 16-bit path is not quietly writing 8.
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into()).unwrap();
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into()).unwrap();
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
assert!(sixteen.bytes.len() > eight.bytes.len());
}
@@ -267,8 +284,8 @@ mod tests {
let mut high = settings(ExportFormat::Jpeg);
high.quality = 98;
let small = export(&frame(128, 128), &low, "a".into()).unwrap();
let large = export(&frame(128, 128), &high, "a".into()).unwrap();
let small = export(&frame(128, 128), &low, "a".into(), None).unwrap();
let large = export(&frame(128, 128), &high, "a".into(), None).unwrap();
assert!(
large.bytes.len() > small.bytes.len(),
"quality 98 produced {} bytes against quality 20's {}",
@@ -281,7 +298,7 @@ mod tests {
fn a_long_edge_export_lands_on_the_requested_size() {
let mut s = settings(ExportFormat::Png);
s.sizing = SizingMode::LongEdge(32);
let out = export(&frame(128, 64), &s, "a".into()).unwrap();
let out = export(&frame(128, 64), &s, "a".into(), None).unwrap();
assert_eq!((out.width, out.height), (32, 16));
}
@@ -293,7 +310,7 @@ mod tests {
let mut s = settings(ExportFormat::Jpeg);
s.colour_space = ColourSpace::DisplayP3;
assert!(matches!(
export(&frame(8, 8), &s, "a".into()),
export(&frame(8, 8), &s, "a".into(), None),
Err(ExportError::ColourSpaceMismatch { .. })
));
}
@@ -314,7 +331,7 @@ mod tests {
s.colour_space = space;
let mut f = frame(8, 8);
f.space = space;
let out = export(&f, &s, "a".into())
let out = export(&f, &s, "a".into(), None)
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
assert!(!out.bytes.is_empty());
}
@@ -326,7 +343,7 @@ mod tests {
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
assert!(
matches!(
export(&frame(8, 8), &settings(format), "a".into()),
export(&frame(8, 8), &settings(format), "a".into(), None),
Err(ExportError::FormatUnsupported(_))
),
"{format:?} should report that it has no encoder yet"
@@ -339,7 +356,7 @@ mod tests {
// Walks `ExportFormat::ALL`, so a format added to the settings page
// cannot quietly reach an encoder that does not handle it.
for format in ExportFormat::ALL {
match export(&frame(8, 8), &settings(format), "a".into()) {
match export(&frame(8, 8), &settings(format), "a".into(), None) {
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),