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:
+29
-12
@@ -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}"),
|
||||
|
||||
Reference in New Issue
Block a user