diff --git a/Cargo.lock b/Cargo.lock index 3874c60..f12a6ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,6 +1349,24 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "dr-export" +version = "0.1.0" +dependencies = [ + "dr-decode", + "dr-gpu", + "dr-pipeline", + "dr-types", + "env_logger", + "jpeg-encoder", + "log", + "png", + "pollster", + "thiserror 2.0.20", + "tiff", + "zune-jpeg 0.4.21", +] + [[package]] name = "dr-gpu" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0e1c692..d974d51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "core/dr-catalog", "core/dr-thumbs", "core/dr-decode", + "core/dr-export", "core/dr-gpu", "core/dr-lens", "core/dr-pipeline", @@ -30,6 +31,7 @@ dr-types = { path = "core/dr-types" } dr-catalog = { path = "core/dr-catalog" } dr-thumbs = { path = "core/dr-thumbs" } dr-decode = { path = "core/dr-decode" } +dr-export = { path = "core/dr-export" } dr-gpu = { path = "core/dr-gpu" } dr-lens = { path = "core/dr-lens" } dr-pipeline = { path = "core/dr-pipeline" } diff --git a/core/dr-export/Cargo.toml b/core/dr-export/Cargo.toml new file mode 100644 index 0000000..bcadf91 --- /dev/null +++ b/core/dr-export/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "dr-export" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# No platform dependency and no filesystem, deliberately. This crate turns a +# rendered frame into *bytes* and a *name*; where those bytes go is the +# caller's problem, because the answer differs by more than a path. On Linux +# it is a file, on Android a SAF document descriptor with no path at all +# (ARCH §6.9), and on either it may be a `PUT` to the server. A crate that +# took a `Path` would work on exactly one of the three. +[dependencies] +dr-types.workspace = true +log.workspace = true +thiserror.workspace = true + +# Encoders. All three are pure Rust and already in the tree, which is the same +# criterion that chose rustls, bundled SQLite and the Lensfun port: a C +# dependency here would be one more thing to satisfy under the Android NDK. +# +# AVIF and JPEG XL (FR-EXP-1) are deliberately absent. The mature encoders for +# both are C or C++ — libaom and libjxl — and ravif, the pure-Rust AVIF path, +# is slow enough to change what a batch export feels like. Neither belongs in +# the first version; see `format` in lib.rs for what happens when one is asked +# for. +jpeg-encoder.workspace = true +png = "0.18" +tiff = "0.11" + +# The example runs the whole path — decode, GPU render, read back, encode, +# write — so it needs what the library deliberately does not: a GPU, a +# pipeline and a decoder. Dev-only, so none of it reaches a dependent. +[dev-dependencies] +dr-decode.workspace = true +dr-gpu.workspace = true +dr-pipeline.workspace = true +env_logger.workspace = true +pollster.workspace = true +zune-jpeg.workspace = true diff --git a/core/dr-export/examples/export.rs b/core/dr-export/examples/export.rs new file mode 100644 index 0000000..b2d415d --- /dev/null +++ b/core/dr-export/examples/export.rs @@ -0,0 +1,177 @@ +//! Export a real file, end to end, from a real image. +//! +//! cargo run -p dr-export --example export -- [out-dir] +//! +//! Deliberately the *whole* path and not a unit test of the encoder: decode, +//! demosaic or upload, run the develop chain on the GPU at full resolution, +//! read the result back through `AdjustPass::export_pixels`, resize, sharpen, +//! encode, and write. A test can prove the JPEG has the right magic bytes; it +//! cannot tell anyone whether the picture came out looking like the picture. + +use std::path::PathBuf; + +use dr_export::{export, Frame, NameContext}; +use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext}; +use dr_pipeline::EditGraph; +use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode}; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn")) + .init(); + + let mut args = std::env::args().skip(1); + let Some(input) = args.next() else { + eprintln!("usage: export [out-dir]"); + std::process::exit(2); + }; + let out_dir = PathBuf::from(args.next().unwrap_or_else(|| ".".into())); + let input = PathBuf::from(input); + + let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu"); + println!("gpu: {} ({:?})", ctx.adapter_name(), ctx.backend()); + + // Decode. A RAW goes through the demosaicer; a JPEG is already RGB and + // takes the same path every operation after the sensor stage does. + let bytes = std::fs::read(&input).expect("read input"); + // From content, not from the extension — dr-decode is emphatic that an + // extension is only a hint. Its own `probe` reports a crate-private + // `Format`, so the SOI marker is checked directly here rather than + // widening that API for an example. + let is_jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]); + let source = if !is_jpeg { + let raw = dr_decode::decode(&bytes).expect("decode raw"); + let demosaicer = Demosaicer::new(&ctx).expect("demosaicer"); + demosaicer.run(&raw).expect("demosaic") + } else { + let (rgba, w, h) = decode_jpeg(&bytes); + DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload") + }; + + // An edit worth seeing in the output, so a broken pipeline is obvious + // rather than subtle. + let mut graph = EditGraph::default_chain(); + graph.set_param( + dr_pipeline::ops::exposure::ID, + dr_pipeline::ops::exposure::EXPOSURE, + 0.35, + ); + graph.set_param( + dr_pipeline::ops::contrast::ID, + dr_pipeline::ops::contrast::CONTRAST, + 18.0, + ); + graph.set_param( + dr_pipeline::ops::saturation::ID, + dr_pipeline::ops::saturation::SATURATION, + 12.0, + ); + + // Full resolution, not the viewport (FR-EXP-9). This is the one thing an + // export must not economise on. + let (sw, sh) = source.size(); + let (fw, fh) = graph.output_size(sw, sh); + println!("source {sw}×{sh}, framed {fw}×{fh}"); + + let mut adjust = AdjustPass::new(&ctx); + let shader = graph.compose(); + let t = std::time::Instant::now(); + adjust.render(&source, &shader, fw, fh).expect("render"); + let (pixels, w, h) = adjust.export_pixels().expect("read back"); + println!( + "rendered {w}×{h} in {:.0} ms", + t.elapsed().as_secs_f32() * 1000.0 + ); + + let frame = Frame::new(w, h, pixels).expect("well-formed frame"); + + let stem = input + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "export".into()); + + // One of each format, so the run exercises every encoder that exists. + for (format, sizing, sharpening) in [ + ( + ExportFormat::Jpeg, + SizingMode::Original, + OutputSharpening::None, + ), + ( + ExportFormat::Jpeg, + SizingMode::LongEdge(1200), + OutputSharpening::Screen, + ), + ( + ExportFormat::Png, + SizingMode::LongEdge(600), + OutputSharpening::Screen, + ), + ( + ExportFormat::Tiff8, + SizingMode::Percentage(25), + OutputSharpening::MattePaper, + ), + ( + ExportFormat::Tiff16, + SizingMode::Percentage(25), + OutputSharpening::MattePaper, + ), + ] { + let settings = ExportSettings { + format, + sizing, + sharpening, + filename_template: "{name}-{dimensions}".into(), + ..Default::default() + }; + + // The size has to be known before the name, because `{dimensions}` is + // part of it — which is why sizing is resolved here and not inside + // `export`. + let (tw, th) = dr_export::target_size(w, h, sizing, settings.allow_upscaling); + let ctx = NameContext { + source_stem: &stem, + sequence: 1, + date: "", + width: tw, + height: th, + preset: "", + }; + let name = dr_export::resolve_name( + &settings.filename_template, + &ctx, + format, + settings.collision, + &|n| out_dir.join(n).exists(), + ) + .expect("a free name"); + + let t = std::time::Instant::now(); + let out = export(&frame, &settings, name).expect("export"); + let path = out_dir.join(&out.name); + std::fs::write(&path, &out.bytes).expect("write"); + println!( + "{:>10} {:>5}×{:<5} {:>8} KB {:>5.0} ms {}", + format.label(), + out.width, + out.height, + out.bytes.len() / 1024, + t.elapsed().as_secs_f32() * 1000.0, + path.display() + ); + } +} + +fn decode_jpeg(bytes: &[u8]) -> (Vec, u32, u32) { + let mut decoder = zune_jpeg::JpegDecoder::new(bytes); + let pixels = decoder.decode().expect("decode jpeg"); + let info = decoder.info().expect("jpeg info"); + let (w, h) = (u32::from(info.width), u32::from(info.height)); + + // zune gives RGB; the GPU upload wants RGBA. + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for px in pixels.chunks_exact(3) { + rgba.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + (rgba, w, h) +} diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs new file mode 100644 index 0000000..c022c69 --- /dev/null +++ b/core/dr-export/src/encode.rs @@ -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, 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 { + 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, 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, 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, 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::(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`), 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, 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 = 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::(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 = 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]); + } +} diff --git a/core/dr-export/src/error.rs b/core/dr-export/src/error.rs new file mode 100644 index 0000000..c56e10c --- /dev/null +++ b/core/dr-export/src/error.rs @@ -0,0 +1,35 @@ +//! TRACES: NFR-ARCH-4 +//! Typed export failures. +//! +//! Every variant is something a caller can act on or report. A batch export +//! runs unattended over hundreds of frames (FR-EXP-7), so "what went wrong +//! with which file" has to survive as data rather than as a log line. + +use dr_types::{ColourSpace, ExportFormat}; + +#[derive(Debug, thiserror::Error)] +pub enum ExportError { + #[error("frame buffer is {got} bytes, expected {expected}")] + FrameSize { expected: usize, got: usize }, + + #[error("frame has no pixels")] + EmptyFrame, + + /// Asked for a format with no encoder in this build. + /// + /// Not a panic and not a silent substitution: the settings page offers + /// AVIF and JPEG XL because FR-EXP-1 lists them, and a build without them + /// should say so rather than quietly writing a JPEG under a `.avif` name. + #[error("{} export is not supported yet", .0.label())] + FormatUnsupported(ExportFormat), + + /// Asked for a colour space the pipeline does not render to. + /// + /// See the note in `export`: the shader clips to sRGB before this crate + /// sees a pixel, so a wider space could only be a mislabelling. + #[error("{} export needs a pipeline that renders to it", .0.label())] + ColourSpaceUnsupported(ColourSpace), + + #[error("encoding failed: {0}")] + Encode(String), +} diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs new file mode 100644 index 0000000..f228562 --- /dev/null +++ b/core/dr-export/src/lib.rs @@ -0,0 +1,286 @@ +//! 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, +} + +impl Frame { + pub fn new(width: u32, height: u32, rgba: Vec) -> Result { + 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, + /// 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 { + // 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}"), + } + } + } +} diff --git a/core/dr-export/src/name.rs b/core/dr-export/src/name.rs new file mode 100644 index 0000000..b9ddb3a --- /dev/null +++ b/core/dr-export/src/name.rs @@ -0,0 +1,316 @@ +//! TRACES: FR-EXP-6 +//! Filename templates and what to do when the name is taken. +//! +//! # Why the caller supplies the "does this exist" test +//! +//! [`resolve_name`] takes a closure rather than looking at a directory, +//! because there is no directory it could look at that would work everywhere. +//! A destination is a path on Linux, a Storage Access Framework tree on +//! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud +//! server reached by PROPFIND. All three can answer "is this name taken", +//! and none of them can be asked the same way. +//! +//! It matters most on Android, where the platform actively works against us: +//! `DocumentsContract.createDocument` renames on collision *by itself*, +//! appending ` (1)` and returning a URI with a name nobody asked for, and it +//! cannot overwrite at all. So every one of the three [`CollisionPolicy`] +//! settings requires knowing the answer before creating anything — which is +//! exactly what this function is shaped for. + +use dr_types::{CollisionPolicy, ExportFormat}; + +/// What a template can refer to. +#[derive(Debug, Clone, Default)] +pub struct NameContext<'a> { + /// The source image's name, without extension — `{name}`. + pub source_stem: &'a str, + /// Position in the batch, 1-based — `{seq}`. + pub sequence: u32, + /// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown. + pub date: &'a str, + /// The export's pixel dimensions — `{dimensions}`. + pub width: u32, + pub height: u32, + /// The preset that produced this export — `{preset}`. Empty where none. + pub preset: &'a str, +} + +/// Expand a template into a filename stem. +/// +/// Unknown tokens are left verbatim rather than dropped. A user who typed +/// `{nmae}` should see it in the output and understand what happened; a +/// silently empty filename is a puzzle, and a template that quietly loses a +/// token produces a directory of files named the same thing. +pub fn expand(template: &str, ctx: &NameContext<'_>) -> String { + let seq = ctx.sequence.to_string(); + let dimensions = format!("{}x{}", ctx.width, ctx.height); + + let mut out = String::with_capacity(template.len() + 16); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let Some(close) = rest[open..].find('}') else { + // An unclosed brace is literal text; there is nothing to expand + // and dropping the remainder would truncate the name. Consumed + // here rather than left for the tail append below, which has + // already had everything before the brace taken from it. + out.push_str(&rest[open..]); + rest = ""; + break; + }; + let token = &rest[open + 1..open + close]; + match token { + "name" => out.push_str(ctx.source_stem), + "seq" => out.push_str(&seq), + "date" => out.push_str(ctx.date), + "dimensions" => out.push_str(&dimensions), + "preset" => out.push_str(ctx.preset), + _ => out.push_str(&rest[open..open + close + 1]), + } + rest = &rest[open + close + 1..]; + } + out.push_str(rest); + + let cleaned = sanitise(&out); + if cleaned.is_empty() { + // Every token was empty — a template of `{preset}` with no preset, on + // an image with no date. Falling back to the source name is the one + // answer that is always available and never collides more than the + // source files themselves do. + return sanitise(ctx.source_stem); + } + cleaned +} + +/// Strip what no filesystem, SAF provider or WebDAV server will take. +/// +/// The intersection of three sets of rules rather than any one of them: an +/// export written to a Nextcloud folder may later sync down to a Windows +/// client, and a name that was legal where it was created is not much comfort +/// on the machine that cannot open it. +fn sanitise(stem: &str) -> String { + let mut out: String = stem + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + c if (c as u32) < 0x20 => '-', + c => c, + }) + .collect(); + // Trailing dots and spaces are legal on Linux and rejected by Windows, + // and a name ending in one is almost always an accident of a template + // whose last token expanded to nothing. + while out.ends_with('.') || out.ends_with(' ') { + out.pop(); + } + out.trim_start().to_string() +} + +/// The filename this export should be written under, honouring the collision +/// policy. +/// +/// `taken` answers whether a name already exists in the destination. Returns +/// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller +/// writes nothing and moves on, which is the whole point of that setting. +pub fn resolve_name( + template: &str, + ctx: &NameContext<'_>, + format: ExportFormat, + collision: CollisionPolicy, + taken: &dyn Fn(&str) -> bool, +) -> Option { + let stem = expand(template, ctx); + let ext = format.extension(); + let first = format!("{stem}.{ext}"); + + if !taken(&first) { + return Some(first); + } + + match collision { + CollisionPolicy::Overwrite => Some(first), + CollisionPolicy::Skip => None, + CollisionPolicy::Increment => { + // Bounded. An unbounded search would spin forever against a + // destination that reports everything as taken — a permission + // error misread as existence, say — and a batch that hangs is + // worse than one that reports a failure. + for n in 1..10_000 { + let candidate = format!("{stem}-{n}.{ext}"); + if !taken(&candidate) { + return Some(candidate); + } + } + log::warn!("{stem}: ten thousand names taken; skipping"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> NameContext<'static> { + NameContext { + source_stem: "IMG_1234", + sequence: 7, + date: "2026-08-16", + width: 2048, + height: 1365, + preset: "Web", + } + } + + fn free(_: &str) -> bool { + false + } + + #[test] + fn the_default_template_is_the_source_name() { + assert_eq!(expand("{name}", &ctx()), "IMG_1234"); + } + + #[test] + fn every_documented_token_expands() { + // The settings page advertises these five in its hint; a token listed + // there and unhandled here would reach the filename verbatim. + assert_eq!(expand("{name}", &ctx()), "IMG_1234"); + assert_eq!(expand("{seq}", &ctx()), "7"); + assert_eq!(expand("{date}", &ctx()), "2026-08-16"); + assert_eq!(expand("{dimensions}", &ctx()), "2048x1365"); + assert_eq!(expand("{preset}", &ctx()), "Web"); + } + + #[test] + fn tokens_combine_with_literal_text() { + assert_eq!( + expand("{date}_{name}_{dimensions}", &ctx()), + "2026-08-16_IMG_1234_2048x1365" + ); + } + + #[test] + fn an_unknown_token_survives_verbatim() { + // A typo the user can see and fix, rather than a name that silently + // lost a component and now collides with every other export. + assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x"); + } + + #[test] + fn an_unclosed_brace_is_literal_text() { + assert_eq!(expand("{name", &ctx()), "{name"); + assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{"); + } + + #[test] + fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() { + // `{preset}` with no preset selected. An empty filename is not a file. + let mut c = ctx(); + c.preset = ""; + assert_eq!(expand("{preset}", &c), "IMG_1234"); + } + + #[test] + fn path_separators_cannot_escape_the_destination() { + // `{name}` comes from a source filename, and a template is user text. + // Either could carry a slash, and an export must not write outside + // the folder that was chosen — nor create a subfolder on the server. + let mut c = ctx(); + c.source_stem = "holiday/2026"; + assert_eq!(expand("{name}", &c), "holiday-2026"); + assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd"); + } + + #[test] + fn characters_windows_rejects_are_replaced() { + // An export may sync down to a Windows client through Nextcloud, and + // a name that was legal where it was written is no comfort there. + assert_eq!(expand(r#"a:b*c?d"eg|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i"); + } + + #[test] + fn trailing_dots_and_spaces_are_trimmed() { + let mut c = ctx(); + c.preset = ""; + assert_eq!(expand("{name}.{preset}", &c), "IMG_1234"); + assert_eq!(expand("{name} ", &ctx()), "IMG_1234"); + } + + #[test] + fn a_free_name_is_used_as_is() { + let got = resolve_name( + "{name}", + &ctx(), + ExportFormat::Jpeg, + CollisionPolicy::Increment, + &free, + ); + assert_eq!(got.as_deref(), Some("IMG_1234.jpg")); + } + + #[test] + fn the_extension_follows_the_format() { + for (format, ext) in [ + (ExportFormat::Jpeg, "jpg"), + (ExportFormat::Png, "png"), + (ExportFormat::Tiff16, "tif"), + ] { + let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free); + assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}"))); + } + } + + #[test] + fn increment_finds_the_first_free_suffix() { + let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg"); + let got = resolve_name( + "{name}", + &ctx(), + ExportFormat::Jpeg, + CollisionPolicy::Increment, + &taken, + ); + assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg")); + } + + #[test] + fn skip_returns_nothing_when_the_name_is_taken() { + // The caller writes no file at all — that is what Skip means, and it + // is why this returns an Option rather than always a name. + let got = resolve_name( + "{name}", + &ctx(), + ExportFormat::Jpeg, + CollisionPolicy::Skip, + &|_| true, + ); + assert_eq!(got, None); + } + + #[test] + fn overwrite_returns_the_taken_name() { + let got = resolve_name( + "{name}", + &ctx(), + ExportFormat::Jpeg, + CollisionPolicy::Overwrite, + &|_| true, + ); + assert_eq!(got.as_deref(), Some("IMG_1234.jpg")); + } + + #[test] + fn increment_gives_up_rather_than_spinning_forever() { + // A destination that reports every name as taken — a permission error + // misread as existence — must not hang the batch. + let got = resolve_name( + "{name}", + &ctx(), + ExportFormat::Jpeg, + CollisionPolicy::Increment, + &|_| true, + ); + assert_eq!(got, None); + } +} diff --git a/core/dr-export/src/sharpen.rs b/core/dr-export/src/sharpen.rs new file mode 100644 index 0000000..667c0cb --- /dev/null +++ b/core/dr-export/src/sharpen.rs @@ -0,0 +1,197 @@ +//! TRACES: FR-EXP-4 +//! Output sharpening, scaled by how far the image was resized. +//! +//! # Why an export needs this at all +//! +//! Downsampling averages neighbouring pixels, and averaging is a low-pass +//! filter: a 24 MP frame reduced to 2048px comes out measurably softer than +//! the same scene shot at 2048px would be. Output sharpening puts back the +//! acuity the resample removed. It is not creative sharpening — that belongs +//! in the develop pipeline, acts on the full-resolution image, and is a +//! different control entirely. +//! +//! # Why the strength depends on the medium +//! +//! The three settings are not intensities dressed up as names. A screen shows +//! a pixel as a pixel, so it needs the least. Ink spreads into paper — dot +//! gain — and matte stock spreads it further than glossy, so a print needs +//! more compensation to arrive looking the same. That is why the paper +//! options are stronger, and why "more" is not simply a slider. + +use dr_types::OutputSharpening; + +/// Radius of the unsharp mask, in pixels. +/// +/// Fixed at a small value rather than scaled with the image: output +/// sharpening compensates for the *resample*, which softens over a pixel or +/// two whatever the size of the frame. A radius that grew with the image +/// would produce haloes on a large export. +const RADIUS: i32 = 1; + +/// Per-setting strength. Applied on top of the resize-derived scaling below. +fn strength(setting: OutputSharpening) -> f32 { + match setting { + OutputSharpening::None => 0.0, + OutputSharpening::Screen => 0.55, + // Ink spread. Matte stock absorbs more than glossy, so it needs the + // heavier hand of the two. + OutputSharpening::GlossyPaper => 0.85, + OutputSharpening::MattePaper => 1.15, + } +} + +/// Sharpen in place-ish: takes the resized buffer and returns it, sharpened. +/// +/// `scale` is the resize factor — destination width over source width. Below +/// 1 the image was reduced and needs compensation; at or above 1 nothing was +/// averaged away and the sharpening is skipped, because sharpening an image +/// that was not softened only adds haloes. +pub fn apply( + mut rgba: Vec, + width: u32, + height: u32, + setting: OutputSharpening, + scale: f32, +) -> Vec { + let base = strength(setting); + if base == 0.0 || scale >= 1.0 || width < 3 || height < 3 { + return rgba; + } + + // A frame reduced to a tenth lost far more than one reduced to nine + // tenths, so the compensation follows the reduction. Capped at the base + // strength: past a point more sharpening is just edge artefacts, and a + // thumbnail is the case where that shows most. + let amount = base * (1.0 - scale).clamp(0.0, 1.0); + + let src = rgba.clone(); + let (w, h) = (width as i32, height as i32); + + for y in 0..h { + for x in 0..w { + for c in 0..3 { + // A 3×3 box blur is the mask. Gaussian would be more correct + // and, at radius 1, indistinguishable — the kernel is nine + // pixels either way. + let mut sum = 0.0f32; + let mut n = 0.0f32; + for dy in -RADIUS..=RADIUS { + for dx in -RADIUS..=RADIUS { + let sx = (x + dx).clamp(0, w - 1); + let sy = (y + dy).clamp(0, h - 1); + sum += f32::from(src[((sy * w + sx) * 4 + c) as usize]); + n += 1.0; + } + } + let blurred = sum / n; + let p = ((y * w + x) * 4 + c) as usize; + let original = f32::from(src[p]); + // Unsharp mask: the original plus its difference from a + // blurred copy, which is the high-frequency detail. + let sharpened = original + (original - blurred) * amount; + rgba[p] = sharpened.round().clamp(0.0, 255.0) as u8; + } + } + } + rgba +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A frame split down the middle: dark left, light right. One vertical + /// edge, which is what sharpening acts on. + fn edge(w: u32, h: u32) -> Vec { + let mut v = Vec::new(); + for _ in 0..h { + for x in 0..w { + let level = if x < w / 2 { 60 } else { 190 }; + v.extend_from_slice(&[level, level, level, 255]); + } + } + v + } + + fn at(buf: &[u8], w: u32, x: u32, y: u32) -> u8 { + buf[((y * w + x) * 4) as usize] + } + + #[test] + fn none_leaves_the_image_exactly_as_it_was() { + let src = edge(16, 8); + let out = apply(src.clone(), 16, 8, OutputSharpening::None, 0.5); + assert_eq!(out, src); + } + + #[test] + fn an_unresized_export_is_not_sharpened() { + // Nothing was averaged away, so there is nothing to compensate for + // and sharpening would only add haloes. + let src = edge(16, 8); + assert_eq!( + apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 1.0), + src + ); + } + + #[test] + fn sharpening_increases_contrast_across_an_edge() { + // The property, stated directly: the dark side of the edge gets + // darker and the light side lighter. + let src = edge(16, 8); + let out = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4); + let (before_dark, before_light) = (at(&src, 16, 7, 4), at(&src, 16, 8, 4)); + let (after_dark, after_light) = (at(&out, 16, 7, 4), at(&out, 16, 8, 4)); + assert!(after_dark < before_dark, "the dark side should deepen"); + assert!(after_light > before_light, "the light side should lift"); + } + + #[test] + fn paper_sharpens_harder_than_screen() { + // Ink spreads; the settings are about the medium, not taste. + let src = edge(16, 8); + let screen = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4); + let matte = apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 0.4); + assert!(at(&matte, 16, 8, 4) > at(&screen, 16, 8, 4)); + assert!(strength(OutputSharpening::MattePaper) > strength(OutputSharpening::GlossyPaper)); + } + + #[test] + fn a_bigger_reduction_sharpens_more() { + let src = edge(16, 8); + let mild = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.9); + let severe = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.1); + assert!(at(&severe, 16, 8, 4) >= at(&mild, 16, 8, 4)); + } + + #[test] + fn a_flat_field_is_untouched() { + // No detail means no high frequencies to amplify. If this drifts, the + // mask is not centred and every sky gains a gradient. + let flat = vec![128u8; 16 * 16 * 4]; + assert_eq!( + apply(flat.clone(), 16, 16, OutputSharpening::MattePaper, 0.3), + flat + ); + } + + #[test] + fn alpha_is_never_touched() { + // The loop runs over three channels for a reason: sharpening alpha + // would put a halo in the transparency of an image that has none. + let out = apply(edge(16, 8), 16, 8, OutputSharpening::MattePaper, 0.2); + for px in out.chunks_exact(4) { + assert_eq!(px[3], 255); + } + } + + #[test] + fn a_frame_too_small_to_have_neighbours_is_left_alone() { + let tiny = vec![10u8; 2 * 2 * 4]; + assert_eq!( + apply(tiny.clone(), 2, 2, OutputSharpening::Screen, 0.5), + tiny + ); + } +} diff --git a/core/dr-export/src/size.rs b/core/dr-export/src/size.rs new file mode 100644 index 0000000..3873aa8 --- /dev/null +++ b/core/dr-export/src/size.rs @@ -0,0 +1,324 @@ +//! TRACES: FR-EXP-3 | FR-EXP-4 +//! Output sizing and resampling. +//! +//! # Why Lanczos +//! +//! FR-EXP-4 asks for "a quality resampler (Lanczos or better)", and the +//! reason is what a cheap one does to a photograph. Box or bilinear +//! downsampling of a 24 MP frame to 2048px averages away detail the sensor +//! resolved and aliases what is left — a brick wall or a distant fence comes +//! back as moiré. Lanczos's negative lobes preserve edge acuity through a +//! large reduction, which is exactly the operation an export performs. +//! +//! Separable: a horizontal pass then a vertical one, which turns an `a²` +//! kernel into `2a` taps per pixel. At the sizes involved that is the +//! difference between an export that feels instant and one that does not. + +use dr_types::SizingMode; + +use crate::Frame; + +/// The Lanczos window. 3 is the photographic default — 2 is softer, and +/// beyond 3 the extra lobes buy ringing rather than detail. +const A: f32 = 3.0; + +/// TRACES: FR-EXP-3 +/// Resolve the requested sizing against a source, honouring the upscale rule. +/// +/// Aspect is preserved in every mode, so only one dimension is ever the +/// requested one. +/// +/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes +/// upscaling opt-in, and a batch of mixed frames must not abort because one +/// was smaller than the target — the user asked for a set of exports, and +/// stopping the run over a frame that came out at source size would be a +/// worse answer than the file itself. +pub fn target_size( + src_w: u32, + src_h: u32, + sizing: SizingMode, + allow_upscaling: bool, +) -> (u32, u32) { + let (src_w, src_h) = (src_w.max(1), src_h.max(1)); + + let (w, h) = match sizing { + SizingMode::Original => (src_w, src_h), + SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h), + SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h), + SizingMode::Percentage(p) => { + let f = f64::from(p) / 100.0; + ( + ((f64::from(src_w) * f).round() as u32).max(1), + ((f64::from(src_h) * f).round() as u32).max(1), + ) + } + }; + + if !allow_upscaling && (w > src_w || h > src_h) { + return (src_w, src_h); + } + (w.max(1), h.max(1)) +} + +/// Scale so that the chosen edge lands on `n`. +fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) { + let n = n.max(1); + if width_is_the_edge { + let h = (f64::from(n) * f64::from(src_h) / f64::from(src_w)).round() as u32; + (n, h.max(1)) + } else { + let w = (f64::from(n) * f64::from(src_w) / f64::from(src_h)).round() as u32; + (w.max(1), n) + } +} + +/// Resample to `(dst_w, dst_h)`, returning tightly packed RGBA8. +/// +/// Returns the source buffer untouched where no scaling is needed, which is +/// the `SizingMode::Original` case and therefore the common one. +pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec { + if dst_w == frame.width && dst_h == frame.height { + return frame.rgba.clone(); + } + + // Horizontal, then vertical. The intermediate is the destination width by + // the *source* height, so the second pass works on as little data as the + // first can leave it. + let horizontal = pass( + &frame.rgba, + frame.width, + frame.height, + dst_w, + frame.height, + true, + ); + pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false) +} + +/// One separable pass. `horizontal` picks the axis being resampled. +fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec { + let (src_len, dst_len) = if horizontal { + (src_w, dst_w) + } else { + (src_h, dst_h) + }; + let ratio = f64::from(src_len) / f64::from(dst_len); + + // Enlarging samples the source at its own frequency; shrinking has to + // widen the kernel to average the pixels being discarded, or the result + // aliases. This is the whole difference between a resample and a + // subsample. + let filter_scale = ratio.max(1.0); + let support = A as f64 * filter_scale; + + let mut out = vec![0u8; (dst_w * dst_h * 4) as usize]; + + for i in 0..dst_len { + // Centre of the destination sample, in source coordinates. + let centre = (f64::from(i) + 0.5) * ratio - 0.5; + let first = ((centre - support).ceil() as i64).max(0); + let last = ((centre + support).floor() as i64).min(i64::from(src_len) - 1); + + // Weights once per output row/column rather than per pixel: they + // depend only on the axis position, and recomputing them per channel + // was most of the cost when this was written the obvious way. + let mut weights = Vec::with_capacity((last - first + 1).max(0) as usize); + let mut total = 0.0f64; + for s in first..=last { + let w = lanczos((f64::from(s as i32) - centre) / filter_scale); + weights.push(w); + total += w; + } + if total == 0.0 { + total = 1.0; + } + + let other = if horizontal { dst_h } else { dst_w }; + for j in 0..other { + let mut acc = [0.0f64; 4]; + for (k, w) in weights.iter().enumerate() { + let s = first as u32 + k as u32; + let (x, y) = if horizontal { (s, j) } else { (j, s) }; + let p = ((y * src_w + x) * 4) as usize; + for c in 0..4 { + acc[c] += f64::from(src[p + c]) * w; + } + } + let (x, y) = if horizontal { (i, j) } else { (j, i) }; + let p = ((y * dst_w + x) * 4) as usize; + for c in 0..4 { + // Lanczos overshoots at edges — that is what makes it look + // sharp — so the result must be clamped rather than wrapped. + out[p + c] = (acc[c] / total).round().clamp(0.0, 255.0) as u8; + } + } + } + out +} + +/// The Lanczos kernel, `sinc(x) * sinc(x / a)`. +fn lanczos(x: f64) -> f64 { + let x = x.abs(); + if x < 1e-9 { + return 1.0; + } + if x >= f64::from(A) { + return 0.0; + } + let px = std::f64::consts::PI * x; + (px.sin() / px) * ((px / f64::from(A)).sin() / (px / f64::from(A))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::frame; + + #[test] + fn original_is_the_source_size() { + assert_eq!( + target_size(6000, 4000, SizingMode::Original, false), + (6000, 4000) + ); + } + + #[test] + fn long_edge_picks_the_longer_dimension_either_way_round() { + assert_eq!( + target_size(6000, 4000, SizingMode::LongEdge(3000), false), + (3000, 2000) + ); + // Portrait: the long edge is now the height. + assert_eq!( + target_size(4000, 6000, SizingMode::LongEdge(3000), false), + (2000, 3000) + ); + } + + #[test] + fn short_edge_picks_the_shorter_dimension_either_way_round() { + assert_eq!( + target_size(6000, 4000, SizingMode::ShortEdge(2000), false), + (3000, 2000) + ); + assert_eq!( + target_size(4000, 6000, SizingMode::ShortEdge(2000), false), + (2000, 3000) + ); + } + + #[test] + fn a_percentage_scales_both_dimensions() { + assert_eq!( + target_size(4000, 3000, SizingMode::Percentage(50), false), + (2000, 1500) + ); + assert_eq!( + target_size(4000, 3000, SizingMode::Percentage(100), false), + (4000, 3000) + ); + } + + #[test] + fn upscaling_is_refused_by_clamping_rather_than_failing() { + // FR-EXP-3: opt-in, and a batch must not abort over one small frame. + assert_eq!( + target_size(800, 600, SizingMode::LongEdge(4000), false), + (800, 600) + ); + assert_eq!( + target_size(800, 600, SizingMode::Percentage(400), false), + (800, 600) + ); + } + + #[test] + fn upscaling_is_honoured_when_asked_for() { + assert_eq!( + target_size(800, 600, SizingMode::LongEdge(1600), true), + (1600, 1200) + ); + } + + #[test] + fn a_square_frame_treats_either_edge_as_the_long_one() { + // The tie has to resolve somewhere, and both answers are the same + // size — but it must not produce a zero or a panic. + assert_eq!( + target_size(1000, 1000, SizingMode::LongEdge(500), false), + (500, 500) + ); + assert_eq!( + target_size(1000, 1000, SizingMode::ShortEdge(500), false), + (500, 500) + ); + } + + #[test] + fn a_size_can_never_round_down_to_nothing() { + // A 1% export of a small frame rounds toward zero, and a zero-pixel + // image is not a file anyone can open. + let (w, h) = target_size(50, 30, SizingMode::Percentage(1), false); + assert!(w >= 1 && h >= 1, "got {w}x{h}"); + } + + #[test] + fn resampling_to_the_same_size_changes_nothing() { + // The `Original` path, which is the common one — it must not spend a + // Lanczos pass to return what it was given. + let f = frame(32, 24); + assert_eq!(resample(&f, 32, 24), f.rgba); + } + + #[test] + fn a_resample_produces_the_right_number_of_pixels() { + let f = frame(64, 48); + assert_eq!(resample(&f, 32, 24).len(), 32 * 24 * 4); + assert_eq!(resample(&f, 100, 75).len(), 100 * 75 * 4); + } + + #[test] + fn a_downscale_preserves_the_gradient_it_was_given() { + // The check that separates a real resample from a buffer of the right + // length: the test frame ramps red left-to-right, so the output must + // too, and its corners must still be near the source's. + let f = frame(128, 128); + let small = resample(&f, 32, 32); + let px = |x: usize, y: usize| small[(y * 32 + x) * 4]; + assert!(px(0, 0) < px(16, 0), "red should rise across the frame"); + assert!(px(16, 0) < px(31, 0)); + // Row-invariant in red, since the ramp is horizontal. + assert!((i32::from(px(16, 0)) - i32::from(px(16, 31))).abs() < 8); + } + + #[test] + fn a_flat_field_survives_a_resample_unchanged() { + // Lanczos rings on edges, which is intended — but a constant field + // has no edges, and any deviation here means the weights do not sum + // to one. That error is invisible on a photograph and glaring on a + // sky. + let flat = Frame::new(64, 64, vec![200; 64 * 64 * 4]).unwrap(); + for byte in resample(&flat, 21, 21) { + assert_eq!(byte, 200, "a constant field must resample to itself"); + } + } + + #[test] + fn an_upscale_also_holds_a_flat_field() { + let flat = Frame::new(16, 16, vec![64; 16 * 16 * 4]).unwrap(); + for byte in resample(&flat, 40, 40) { + assert_eq!(byte, 64); + } + } + + #[test] + fn the_kernel_is_one_at_the_centre_and_zero_past_its_window() { + assert!((lanczos(0.0) - 1.0).abs() < 1e-9); + assert_eq!(lanczos(3.0), 0.0); + assert_eq!(lanczos(4.5), 0.0); + // Zero at the integers inside the window, which is what makes an + // unscaled resample an identity. + assert!(lanczos(1.0).abs() < 1e-9); + assert!(lanczos(2.0).abs() < 1e-9); + } +} diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index f62e29f..39c3c22 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -37,7 +37,10 @@ const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS; /// never arrives, and an unbounded loop would hang the interface rather than /// surfacing the error. Set far above any plausible completion — the copy this /// waits on is milliseconds — so it is reached only when something is wrong. -#[cfg(any(test, feature = "readback"))] +/// +/// Ungated along with `export_pixels`: an export reads pixels back in a +/// shipping build, and the bound that stops a lost device hanging the app +/// applies at least as much there as it does to the display bridge. const READBACK_POLL_LIMIT: u32 = 100_000; /// Runs composed operation chains against demosaiced images. @@ -323,6 +326,33 @@ impl AdjustPass { /// keeps it out of a shipping build. #[cfg(any(test, feature = "readback"))] pub fn read_output(&self) -> Result<(Vec, u32, u32), GpuError> { + self.copy_output() + } + + /// TRACES: FR-EXP-9 + /// Copy the output to the CPU **for export**. + /// + /// The same transfer as [`Self::read_output`] and deliberately not the + /// same method, because the two are opposites in intent and only one of + /// them is a defect. + /// + /// Reading pixels back to display them is what ARCH §6.1 forbids and AC-8 + /// asserts against: the compositor could have sampled that texture where + /// it stood, and the round-trip costs 96% of the frame at 4K. Reading them + /// back to *encode a JPEG* is not a shortcut around anything — a file is + /// made of bytes on the CPU, and there is no path to one that does not + /// pass through here. + /// + /// So this is ungated where `read_output` is behind a feature: an export + /// must work in a shipping build, and the gate exists to keep the display + /// bridge out of one. Keeping them separate also means the instrumentation + /// AC-8 calls for can count display readbacks without counting exports. + pub fn export_pixels(&self) -> Result<(Vec, u32, u32), GpuError> { + self.copy_output() + } + + /// The transfer itself, shared by both readers above. + fn copy_output(&self) -> Result<(Vec, u32, u32), GpuError> { let Some(target) = self.target.as_ref() else { return Err(GpuError::Readback("nothing rendered yet".into())); }; diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 342861e..c088e1e 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -13,8 +13,8 @@ pub mod settings; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; pub use settings::{ - CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, OutputSharpening, - Settings, SizingMode, + CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, ExportTarget, + OutputSharpening, Settings, SizingMode, }; /// Identifies a granted library location — a directory on Linux, a persisted diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 4970c1a..dccd83f 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -166,11 +166,21 @@ pub struct ExportSettings { /// a user who wanted coordinates and has to re-export — costs a minute. pub strip_location: bool, + /// Whether [`Self::destination`] names a folder on this device or on the + /// connected server. + pub target: ExportTarget, + /// Destination folder. Empty means "ask each time". /// /// Empty rather than a guessed `~/Pictures`: a silent default destination /// is how exports end up somewhere the user never looks, and this is the /// one field where the app genuinely does not know the answer. + /// + /// What the string *is* depends on [`Self::target`], and on the platform: + /// a filesystem path on Linux, a SAF tree URI on Android, or a path under + /// the library root when the target is the server. The same widening + /// `SourceRef` performs for reads (ARCH §3.1) — no core API takes a + /// `Path`, because Android has none to give. pub destination: String, } @@ -189,11 +199,65 @@ impl Default for ExportSettings { filename_template: "{name}".to_string(), collision: CollisionPolicy::Increment, strip_location: true, + target: ExportTarget::Device, destination: String::new(), } } } +/// Where an export is written (FR-EXP-6). +/// +/// # Why the server is a first-class destination +/// +/// Not a convenience. On Android it is the *only* destination that needs no +/// platform work at all: a device export has to go through the Storage Access +/// Framework, which provides no filesystem path (ARCH §6.9), cannot overwrite +/// through `createDocument`, and renames on collision by itself — so all three +/// [`CollisionPolicy`] settings need the destination listed first and the +/// whole path needs JNI. A server export is a `PUT` against +/// [`create_dir`](../../dr_sync/trait.RemoteBackend.html) and behaves +/// identically on both platforms. +/// +/// It is also where the photographs already are. A library that lives on +/// Nextcloud and exports to a phone's local storage has put the output +/// somewhere the user's other devices cannot see, which is rarely what was +/// meant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExportTarget { + /// A folder on this device — a path on Linux, a SAF tree grant on Android. + /// + /// The default, and deliberately so despite the above: an export the user + /// cannot immediately open is a surprise, and on a desktop the file + /// manager is the obvious next step. The server is a choice, not an + /// assumption about where someone wants their pictures. + #[default] + Device, + /// A folder on the connected account, created if absent. + Remote, +} + +impl ExportTarget { + pub const ALL: [Self; 2] = [Self::Device, Self::Remote]; + + pub fn label(self) -> &'static str { + match self { + Self::Device => "This device", + Self::Remote => "Nextcloud", + } + } + + /// Whether a destination for this target has to travel over the network. + /// + /// What decides that an export is staged and queued rather than written + /// and finished: a remote destination is unreachable exactly as often as + /// the library is (FR-NC-10), and an export must not fail because a train + /// went into a tunnel. + pub fn is_remote(self) -> bool { + matches!(self, Self::Remote) + } +} + /// Output container and codec (FR-EXP-1). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -436,6 +500,14 @@ impl Settings { if self.export.filename_template.trim().is_empty() { self.export.filename_template = "{name}".to_string(); } + + // A destination of whitespace is not a destination, and it is not the + // same as "ask each time" until it is actually empty — a path of three + // spaces would otherwise be created, on the server, as a folder named + // with three spaces. + if self.export.destination.trim().is_empty() { + self.export.destination.clear(); + } } } @@ -552,6 +624,70 @@ mod tests { assert!(ExportSettings::default().strip_location); } + #[test] + fn exports_go_to_this_device_unless_asked_otherwise() { + // A first run must not upload someone's pictures to a server because + // the app thought that was tidier. + assert_eq!(ExportSettings::default().target, ExportTarget::Device); + assert!(!ExportTarget::default().is_remote()); + } + + #[test] + fn a_file_written_before_the_target_existed_still_loads() { + // The compatibility that matters: every settings file already on disk + // has a `destination` and no `target`. Losing the destination over the + // new field would silently move an export somewhere else. + let json = r#"{"export": {"destination": "/home/x/Exports", "quality": 80}}"#; + let parsed: Settings = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.export.destination, "/home/x/Exports"); + assert_eq!(parsed.export.quality, 80); + assert_eq!( + parsed.export.target, + ExportTarget::Device, + "an older file predates the choice, and its path was a local one" + ); + } + + #[test] + fn a_remote_target_round_trips_through_json() { + let mut s = Settings::default(); + s.export.target = ExportTarget::Remote; + s.export.destination = "Exports/2026".to_string(); + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + } + + #[test] + fn a_whitespace_destination_becomes_ask_each_time() { + // Not merely tidiness: a remote export would otherwise MKCOL a folder + // whose name is three spaces, on the user's server, and then put + // pictures in it. + let mut s = Settings::default(); + s.export.destination = " ".to_string(); + s.sanitise(); + assert!(s.export.destination.is_empty()); + } + + #[test] + fn sanitise_leaves_a_real_destination_alone() { + let mut s = Settings::default(); + s.export.target = ExportTarget::Remote; + s.export.destination = "Exports/Prints".to_string(); + s.sanitise(); + assert_eq!(s.export.destination, "Exports/Prints"); + assert!(s.export.target.is_remote()); + } + + #[test] + fn every_target_can_be_named_in_the_interface() { + // The settings page builds its choices from `ALL` and labels them from + // `label`, so a variant missing from either is unreachable. + assert_eq!(ExportTarget::ALL.len(), 2); + for t in ExportTarget::ALL { + assert!(!t.label().is_empty()); + } + } + #[test] fn sanitise_clamps_an_out_of_range_quality() { let mut s = Settings::default(); diff --git a/docs/traceability.md b/docs/traceability.md index af6023b..3a352c8 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 102 | -| TRACES tags found | 145 | +| Source files scanned | 109 | +| TRACES tags found | 154 | | Requirements defined | 151 | | Requirements covered | 73 | | **Coverage** | **48.3%** (73/151) | @@ -58,14 +58,14 @@ _None._ | FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:132`](../core/dr-gpu/src/lib.rs#L132) | | FR-DSP-1 | [`ui/dr-ui/src/lib.rs:45`](../ui/dr-ui/src/lib.rs#L45) | -| FR-EXP-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-2 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-4 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-8 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:91`](../core/dr-export/src/lib.rs#L91), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | @@ -88,10 +88,10 @@ _None._ | FR-UI-1 | [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:53`](../ui/dr-ui/src/lib.rs#L53) | | FR-UI-2 | [`ui/dr-ui/src/lib.rs:53`](../ui/dr-ui/src/lib.rs#L53) | | FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-4 | [`ui/dr-ui/ui/app.slint:984`](../ui/dr-ui/ui/app.slint#L984) | +| FR-UI-4 | [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1463`](../ui/dr-ui/src/lib.rs#L1463), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) | +| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index c676b8d..404f7f5 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -26,7 +26,8 @@ use std::rc::Rc; use dr_types::settings::budget; use dr_types::{ - CollisionPolicy, ColourSpace, ExportFormat, OutputSharpening, Settings, SizingMode, + CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, OutputSharpening, Settings, + SizingMode, }; use slint::ComponentHandle; @@ -158,7 +159,18 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision)); window.set_settings_strip_location(s.export.strip_location); + window.set_settings_target_labels(labels(ExportTarget::ALL.iter().map(|t| t.label()))); + window.set_settings_target_selected(index_of(&ExportTarget::ALL, &s.export.target)); window.set_settings_destination(s.export.destination.clone().into()); + // The field means different things either side of the choice, and a + // placeholder saying which is cheaper than a paragraph under it. + window.set_settings_destination_hint( + match s.export.target { + ExportTarget::Device => "Choose a folder…", + ExportTarget::Remote => "A folder under the library root, created if absent", + } + .into(), + ); window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into()); } @@ -428,6 +440,27 @@ where }); } + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_target_changed(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(&t) = ExportTarget::ALL.get(i.max(0) as usize) { + // The destination is cleared with the switch. A path and a + // remote folder are not the same kind of string, and carrying + // `/home/x/Exports` across to the server would offer to create + // a folder called `home` at the library root. + ctl.edit(|s| { + if s.export.target != t { + s.export.target = t; + s.export.destination.clear(); + } + }); + } + render(&w, &ctl); + }); + } + // --- reset --------------------------------------------------------- { let weak = window.as_weak(); diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 54ddb98..a1bf683 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -525,6 +525,9 @@ export component AppWindow inherits Window { in property settings-collision-selected: 0; in property settings-strip-location: true; in property settings-destination: ""; + in property settings-destination-hint; + in property <[string]> settings-target-labels; + in property settings-target-selected: 0; in property settings-error: ""; callback settings-format-picked(int); @@ -538,6 +541,7 @@ export component AppWindow inherits Window { callback settings-collision-picked(int); callback settings-strip-location-toggled(bool); callback settings-destination-changed(string); + callback settings-target-changed(int); callback settings-reset(); /// Show the settings page. Reads the file first, so a second instance's @@ -679,6 +683,9 @@ export component AppWindow inherits Window { collision-selected: root.settings-collision-selected; strip-location: root.settings-strip-location; destination: root.settings-destination; + destination-hint: root.settings-destination-hint; + target-labels: root.settings-target-labels; + target-selected: root.settings-target-selected; error: root.settings-error; format-picked(i) => { root.settings-format-picked(i); } @@ -692,6 +699,7 @@ export component AppWindow inherits Window { collision-picked(i) => { root.settings-collision-picked(i); } strip-location-toggled(on) => { root.settings-strip-location-toggled(on); } destination-changed(t) => { root.settings-destination-changed(t); } + target-picked(i) => { root.settings-target-changed(i); } activity-rows: root.activity-rows; activity-running: root.activity-running; diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index e9ecc9d..65e3a7e 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -325,6 +325,11 @@ export component SettingsPage inherits Rectangle { in property collision-selected: 0; in property strip-location: true; in-out property destination; + // What the destination field means depends on this, so the placeholder + // comes from Rust alongside it rather than being written twice here. + in property destination-hint; + in property <[string]> target-labels; + in property target-selected: 0; callback format-picked(int); callback quality-changed(int); @@ -337,6 +342,7 @@ export component SettingsPage inherits Rectangle { callback collision-picked(int); callback strip-location-toggled(bool); callback destination-changed(string); + callback target-picked(int); /// A save failed. The page's whole contract is that what it shows is /// stored, so this cannot be swallowed. @@ -684,12 +690,24 @@ export component SettingsPage inherits Rectangle { picked(i) => { root.collision-picked(i); } } + // Where the file lands, before what it is called: on + // Android the answer decides whether an export needs + // the Storage Access Framework at all, and on any + // platform a server destination is reached over a + // network that may not be there. + ChoiceRow { + label: "Export to"; + options: root.target-labels; + selected: root.target-selected; + picked(i) => { root.target-picked(i); } + } + EntryRow { label: "Destination"; hint: "empty asks each time"; text <=> root.destination; field-width: 320px; - placeholder: "Ask each time"; + placeholder: root.destination-hint; accepted(t) => { root.destination-changed(t); } }