Files
DarkRoom/core/dr-export/src/lib.rs
T
dtourolle 42d11d919b cargo fmt and clippy across the panorama work, and one lint master carried
The dr-face comparison is master's: a negated partial-order test on the
eye box's width, rewritten as the two conditions it meant.
2026-09-19 15:53:06 +02:00

397 lines
15 KiB
Rust

//! 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<u8>,
/// 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<u8>) -> Result<Self, ExportError> {
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<u8>,
space: ColourSpace,
) -> Result<Self, ExportError> {
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<u8>,
/// 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<Encoded, ExportError> {
// 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}"),
}
}
}
}