Export sizing could bound an image but not fix it. Long edge, short edge and percentage all preserve the aspect ratio by letting one dimension fall where it may, which is right for most work and useless against a display that accepts one resolution and rejects everything else — a television's art mode, a digital frame, a wallpaper slot. FR-EXP-3 has always listed both halves of the answer, and this adds them. **Fit box** scales to fit inside a width and height, so nothing is thrown away and the result is smaller than the box on one axis unless the crop already matches it. **Fill box** scales to cover the box and cuts the overhang off the middle, so the file is exactly the pixels asked for. Fill is the only mode in the file that discards image data, so two things about it are worth stating. The overhang comes off symmetrically: the crop tool is where a photographer decides which part of a frame survives, and this stage having an opinion of its own would fight it. And locking the crop to the same ratio leaves nothing here to cut, which is the workflow the two features are meant to be used in. With upscaling off and a source too small to cover, a fill box keeps its *shape* rather than falling back to the source's: exporting a 3:2 file where 16:9 was asked for is silently wrong in exactly the way the mode exists to prevent, so the box shrinks instead. The existing rule — clamp, never fail — is otherwise unchanged. Four panel sizes are offered as buttons beside the fields. Getting 3840 x 2160 by typing four digits twice is a step at which the mistake is discovered after the upload rather than before it. They fill in the numbers and nothing else, in particular not the fit/fill choice: both are legitimate against a screen, and guessing would discard the edges of a photograph for a user who wanted them. The list is panels rather than platforms, because a screen has one exact pixel count for ever where "what a photo site wants" would rot in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
393 lines
14 KiB
Rust
393 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,
|
|
);
|
|
|
|
// 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}"),
|
|
}
|
|
}
|
|
}
|
|
}
|