Files
DarkRoom/core/dr-export/src/lib.rs
T
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00

287 lines
10 KiB
Rust

//! TRACES: FR-EXP-1 | 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 name;
mod sharpen;
mod size;
pub use error::ExportError;
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 sRGB — 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>,
}
impl Frame {
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> 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,
})
}
}
/// 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.
pub fn export(
frame: &Frame,
settings: &ExportSettings,
name: String,
) -> Result<Encoded, ExportError> {
// Refused rather than mislabelled. The pipeline's final stage encodes to
// sRGB and clamps to its gamut (see `encode_srgb` in the generated
// shader), so the pixels arriving here have already lost anything a wider
// space could have carried. Tagging them Display P3 would produce a file
// that claims a gamut it does not contain — worse than not offering it,
// because the claim survives into everything downstream.
//
// Honouring the other spaces is a pipeline change, not an encoder one:
// the shader has to be told what to encode to (FR-EXP-2).
if settings.colour_space != ColourSpace::Srgb {
return Err(ExportError::ColourSpaceUnsupported(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)?;
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(),
)
.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()).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();
// 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()).unwrap();
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into()).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()).unwrap();
let large = export(&frame(128, 128), &high, "a".into()).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()).unwrap();
assert_eq!((out.width, out.height), (32, 16));
}
#[test]
fn a_colour_space_the_pipeline_cannot_produce_is_refused_not_mislabelled() {
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
// survives into everything downstream. Better to fail loudly.
let mut s = settings(ExportFormat::Jpeg);
s.colour_space = ColourSpace::DisplayP3;
assert!(matches!(
export(&frame(8, 8), &s, "a".into()),
Err(ExportError::ColourSpaceUnsupported(_))
));
}
#[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()),
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()) {
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}"),
}
}
}
}