//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3 //! Turning a rendered frame into a file's worth of bytes. //! //! # What this crate is, and is not //! //! It is: resize, output sharpening, encode, and the name the result should //! be given. It is not: a filesystem, a network client, or a job queue. //! [`export`] returns [`Encoded`] — bytes and a filename — and the caller //! decides where that lands. //! //! That boundary is not fastidiousness. An export has three possible //! destinations and they have nothing in common: a path on Linux, a Storage //! Access Framework document on Android where there *is* no path //! (ARCH §6.9), and a `PUT` to a Nextcloud folder. A crate that wrote the //! file itself would serve one of them and be rewritten for the other two. //! //! # Order of operations //! //! Resize, then sharpen, then encode. Sharpening after the resize is the //! whole point of output sharpening (FR-EXP-4): it compensates for the //! softening the resample introduced, so its strength has to scale with how //! much scaling actually happened. Sharpening first and then shrinking would //! throw the sharpened detail away. use dr_types::{ColourSpace, ExportFormat, ExportSettings}; mod dng; mod encode; mod error; mod exif; pub mod icc; mod inscribed; mod metadata; mod name; mod sharpen; mod size; pub use dng::{write_linear_dng, DngProfile}; pub use error::ExportError; pub use inscribed::{Inscribed, Rect}; pub use metadata::SourceMetadata; pub use name::{resolve_name, NameContext}; pub use size::target_size; /// A rendered frame, as the adjust pass produced it. /// /// 8-bit RGBA, display-encoded in [`Self::space`] — the format /// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is /// carried but never meaningful: the pipeline writes 1.0 everywhere, and no /// operation produces transparency. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Frame { pub width: u32, pub height: u32, /// Tightly packed RGBA8, `width * height * 4` bytes. pub rgba: Vec, /// TRACES: FR-EXP-2 /// The space the shader encoded these pixels into. /// /// Travels with the pixels rather than being asserted at the point of /// encoding, because it is a fact about them and not a preference. The /// conversion happened in the generated shader, before the clip to 0..1, /// and nothing downstream can undo or redo it — a frame clipped to sRGB /// has already lost whatever a wider space would have carried. /// /// Making it a field is what lets [`export`] refuse to label a frame as /// something it is not, rather than trusting a caller to have rendered /// what it asked for. pub space: ColourSpace, } impl Frame { /// A frame the pipeline rendered in sRGB — what /// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose) /// produces, and so what the display path hands over. /// /// An export in a wider space must render its own frame with /// `compose_for` and declare it through [`Self::in_space`]. Defaulting /// here rather than demanding the space at every call site keeps the /// common case honest by construction: a caller that has not thought /// about colour is describing sRGB, and sRGB is what it rendered. pub fn new(width: u32, height: u32, rgba: Vec) -> Result { Self::in_space(width, height, rgba, ColourSpace::Srgb) } /// A frame rendered into a stated colour space. pub fn in_space( width: u32, height: u32, rgba: Vec, space: ColourSpace, ) -> Result { let expected = width as usize * height as usize * 4; if rgba.len() != expected { return Err(ExportError::FrameSize { expected, got: rgba.len(), }); } if width == 0 || height == 0 { return Err(ExportError::EmptyFrame); } Ok(Self { width, height, rgba, space, }) } } /// The finished article: what to write, and what to call it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Encoded { /// Filename including extension. Never a path — the destination folder is /// the caller's, and on Android it is not expressible as one anyway. pub name: String, pub bytes: Vec, /// What the image was actually written at, after sizing and the upscaling /// guard. Worth reporting: a batch that silently exported at source size /// because the request was larger has done something the user should know. pub width: u32, pub height: u32, } /// Resize, sharpen and encode one frame. /// /// `name` is the filename already resolved by [`resolve_name`] — passed in /// rather than derived here because resolving it needs to know what is /// already in the destination, which this crate cannot see. /// /// TRACES: FR-EXP-9 /// The frame is expected to be a **full-resolution** render. Nothing here /// enforces that, because nothing here can tell a full render from a /// viewport-sized one; the caller renders at the framed output size and this /// 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 { // TRACES: FR-EXP-2 // Refused rather than mislabelled. Every space the settings page offers // now works, but only if the *frame* was rendered into it: the conversion // and the clip both happen in the generated shader, so pixels that arrive // clipped to sRGB have already lost whatever a wider space would have // carried, and no amount of profile-writing here brings it back. // // The caller's fix is to compose with `EditGraph::compose_for(space)` // before rendering. Until it does, this is an accurate error where the // alternative would be a file that claims a gamut it does not contain — // and that claim survives into everything downstream. if frame.space != settings.colour_space { return Err(ExportError::ColourSpaceMismatch { rendered: frame.space, requested: settings.colour_space, }); } if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) { return Err(ExportError::FormatUnsupported(settings.format)); } let (width, height) = size::target_size( frame.width, frame.height, settings.sizing, settings.allow_upscaling, ); // TRACES: FR-EXP-3 // One mode promises exact dimensions rather than a bound on them, and it // is the only place the fit/fill distinction survives: `target_size` has // already reported what the file will be either way. let resized = if settings.sizing.crops_to_fill() { size::resample_filling(frame, width, height) } else { size::resample(frame, width, height) }; // Scaled by how much the image actually shrank: a full-size export needs // no compensation, and a thumbnail needs a great deal. 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, source)?; Ok(Encoded { name, bytes, width, height, }) } #[cfg(test)] mod tests { use super::*; use dr_types::SizingMode; /// A frame with a recognisable gradient, so a resample can be checked for /// having done something rather than merely returned the right length. pub(crate) fn frame(w: u32, h: u32) -> Frame { let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { rgba.push((x * 255 / w.max(1)) as u8); rgba.push((y * 255 / h.max(1)) as u8); rgba.push(128); rgba.push(255); } } Frame::new(w, h, rgba).expect("well-formed") } fn settings(format: ExportFormat) -> ExportSettings { ExportSettings { format, ..Default::default() } } #[test] fn a_frame_rejects_a_buffer_of_the_wrong_length() { // The one error that would otherwise surface as a panic deep in an // encoder, or worse, as a file of garbage. assert!(matches!( Frame::new(4, 4, vec![0; 10]), Err(ExportError::FrameSize { .. }) )); } #[test] fn jpeg_export_produces_a_jpeg() { let out = export( &frame(64, 48), &settings(ExportFormat::Jpeg), "a.jpg".into(), None, ) .unwrap(); // SOI marker. Cheap, and it catches an encoder wired to the wrong // format far more directly than a byte count would. assert_eq!(&out.bytes[..2], &[0xFF, 0xD8]); assert_eq!((out.width, out.height), (64, 48)); } #[test] fn png_export_produces_a_png() { 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(), 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*"), "{format:?} did not produce a TIFF header" ); } } #[test] 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(), None, ) .unwrap(); let sixteen = export( &frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None, ) .unwrap(); assert!(sixteen.bytes.len() > eight.bytes.len()); } #[test] fn quality_changes_the_size_of_a_jpeg() { // The setting is plumbed all the way to the encoder rather than // accepted and dropped, which a size-independent output would show. let mut low = settings(ExportFormat::Jpeg); low.quality = 20; let mut high = settings(ExportFormat::Jpeg); high.quality = 98; 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 {}", large.bytes.len(), small.bytes.len() ); } #[test] 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(), None).unwrap(); assert_eq!((out.width, out.height), (32, 16)); } #[test] fn a_frame_rendered_in_one_space_is_not_labelled_another() { // A file tagged Display P3 carrying sRGB-clipped pixels is a lie that // survives into everything downstream. The frame carries the space it // was rendered in precisely so this cannot be waved through. let mut s = settings(ExportFormat::Jpeg); s.colour_space = ColourSpace::DisplayP3; assert!(matches!( export(&frame(8, 8), &s, "a".into(), None), Err(ExportError::ColourSpaceMismatch { .. }) )); } #[test] fn every_colour_space_exports_when_the_frame_was_rendered_in_it() { // The other side of the refusal above, and what FR-EXP-2 actually // asks for: a frame the pipeline encoded into a wide space reaches a // file, in every format that has an encoder. for space in ColourSpace::ALL { for format in [ ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff8, ExportFormat::Tiff16, ] { let mut s = settings(format); s.colour_space = space; let mut f = frame(8, 8); f.space = space; let out = export(&f, &s, "a".into(), None) .unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}")); assert!(!out.bytes.is_empty()); } } } #[test] fn the_formats_without_an_encoder_say_so() { for format in [ExportFormat::Avif, ExportFormat::JpegXl] { assert!( matches!( export(&frame(8, 8), &settings(format), "a".into(), None), Err(ExportError::FormatUnsupported(_)) ), "{format:?} should report that it has no encoder yet" ); } } #[test] fn every_offered_format_either_encodes_or_explains_itself() { // 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(), 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}"), } } } }