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>
This commit is contained in:
2026-08-16 22:26:37 +02:00
co-authored by Claude Opus 5
parent d7aeafaf84
commit 151dcc3c02
17 changed files with 1805 additions and 15 deletions
+169
View File
@@ -0,0 +1,169 @@
//! TRACES: FR-EXP-1 | FR-EXP-8
//! The encoders.
//!
//! All four write RGB, not RGBA. The pipeline produces an opaque frame — no
//! operation makes a pixel transparent, and the shader writes 1.0 into alpha
//! unconditionally — so a fourth channel would be a third more bytes carrying
//! the same value in every pixel, and a PNG that some tools then treat as
//! having meaningful transparency.
//!
//! # Metadata
//!
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
//! satisfies it in the strongest possible way: there is no EXIF block, so
//! there is no GPS tag, no serial number, and no lens history in the file
//! that leaves the machine.
//!
//! The other half of FR-EXP-8 — *retaining* camera and copyright metadata
//! when the user asks for it — is not implemented, and cannot be faked by
//! omission. It needs the source's EXIF carried through `dr-decode` and
//! re-serialised here, which is a piece of work in its own right and belongs
//! with the batch-export path that would make it worth having.
use dr_types::{ExportFormat, ExportSettings};
use crate::ExportError;
/// Encode a resized, sharpened RGBA buffer to the requested format.
pub fn encode(
rgba: &[u8],
width: u32,
height: u32,
settings: &ExportSettings,
) -> Result<Vec<u8>, ExportError> {
match settings.format {
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality),
ExportFormat::Png => png(rgba, width, height),
ExportFormat::Tiff8 => tiff8(rgba, width, height),
ExportFormat::Tiff16 => tiff16(rgba, width, height),
other => Err(ExportError::FormatUnsupported(other)),
}
}
/// Drop alpha, which the pipeline never varies.
fn rgb(rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
out
}
fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, ExportError> {
let mut bytes = Vec::new();
let encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
encoder
.encode(
&rgb(rgba),
width as u16,
height as u16,
jpeg_encoder::ColorType::Rgb,
)
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes)
}
fn png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| ExportError::Encode(e.to_string()))?;
writer
.write_image_data(&rgb(rgba))
.map_err(|e| ExportError::Encode(e.to_string()))?;
writer
.finish()
.map_err(|e| ExportError::Encode(e.to_string()))?;
}
Ok(bytes)
}
fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
use tiff::encoder::{colortype, TiffEncoder};
let mut bytes = std::io::Cursor::new(Vec::new());
let mut encoder =
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.write_image::<colortype::RGB8>(width, height, &rgb(rgba))
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes.into_inner())
}
/// 16-bit TIFF, for work continuing in another editor.
///
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
/// target (`AdjustPass::FORMAT` is `Rgba8Unorm`, and the generated shader
/// declares `texture_storage_2d<rgba8unorm, write>`), so the samples widened
/// here hold eight bits of information in a sixteen-bit container. The file
/// is a correct 16-bit TIFF and will round-trip through any editor without
/// further loss — but it does not resurrect precision the pipeline already
/// quantised away.
///
/// Making it mean what it says is a pipeline change rather than an encoder
/// one: the composer has to be told what format to write, and export has to
/// ask for the wide one (FR-EXP-9). Until then this is a container promotion,
/// which is still the right thing to hand an editor that works in 16-bit.
fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
use tiff::encoder::{colortype, TiffEncoder};
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
// shift maps it to 65280 and makes white slightly grey.
let wide: Vec<u16> = rgb(rgba).iter().map(|&v| u16::from(v) * 257).collect();
let mut bytes = std::io::Cursor::new(Vec::new());
let mut encoder =
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.write_image::<colortype::RGB16>(width, height, &wide)
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_is_dropped_before_encoding() {
let rgba = vec![1, 2, 3, 255, 4, 5, 6, 255];
assert_eq!(rgb(&rgba), vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn white_widens_to_full_scale_not_almost() {
// The bug a left-shift introduces: 255 << 8 is 65280, so pure white
// comes out a quarter of a percent grey in every 16-bit export.
assert_eq!(u16::from(255u8) * 257, u16::MAX);
assert_eq!(u16::from(0u8) * 257, 0);
// And the midpoint stays the midpoint.
assert_eq!(u16::from(128u8) * 257, 32896);
}
#[test]
fn a_png_round_trips_its_pixels_exactly() {
// PNG is lossless, so this is a real end-to-end check that the buffer
// reaching the encoder is the one we think it is — channel order
// included, which a size assertion would not catch.
let rgba: Vec<u8> = vec![
255, 0, 0, 255, // red
0, 255, 0, 255, // green
0, 0, 255, 255, // blue
10, 20, 30, 255,
];
let bytes = png(&rgba, 2, 2).unwrap();
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut out).unwrap();
assert_eq!((info.width, info.height), (2, 2));
assert_eq!(info.color_type, png::ColorType::Rgb);
assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]);
}
}