`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
14 KiB
Rust
385 lines
14 KiB
Rust
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
|
//! 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 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;
|
|
|
|
/// 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,
|
|
);
|
|
|
|
let resized = 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}"),
|
|
}
|
|
}
|
|
}
|
|
}
|