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:
Generated
+18
@@ -1349,6 +1349,24 @@ dependencies = [
|
|||||||
"zune-jpeg 0.4.21",
|
"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]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"core/dr-catalog",
|
"core/dr-catalog",
|
||||||
"core/dr-thumbs",
|
"core/dr-thumbs",
|
||||||
"core/dr-decode",
|
"core/dr-decode",
|
||||||
|
"core/dr-export",
|
||||||
"core/dr-gpu",
|
"core/dr-gpu",
|
||||||
"core/dr-lens",
|
"core/dr-lens",
|
||||||
"core/dr-pipeline",
|
"core/dr-pipeline",
|
||||||
@@ -30,6 +31,7 @@ dr-types = { path = "core/dr-types" }
|
|||||||
dr-catalog = { path = "core/dr-catalog" }
|
dr-catalog = { path = "core/dr-catalog" }
|
||||||
dr-thumbs = { path = "core/dr-thumbs" }
|
dr-thumbs = { path = "core/dr-thumbs" }
|
||||||
dr-decode = { path = "core/dr-decode" }
|
dr-decode = { path = "core/dr-decode" }
|
||||||
|
dr-export = { path = "core/dr-export" }
|
||||||
dr-gpu = { path = "core/dr-gpu" }
|
dr-gpu = { path = "core/dr-gpu" }
|
||||||
dr-lens = { path = "core/dr-lens" }
|
dr-lens = { path = "core/dr-lens" }
|
||||||
dr-pipeline = { path = "core/dr-pipeline" }
|
dr-pipeline = { path = "core/dr-pipeline" }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Export a real file, end to end, from a real image.
|
||||||
|
//!
|
||||||
|
//! cargo run -p dr-export --example export -- <file.jpg|file.cr2> [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 <file.jpg|file.cr2> [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<u8>, 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)
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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<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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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"e<f>g|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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
setting: OutputSharpening,
|
||||||
|
scale: f32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
/// never arrives, and an unbounded loop would hang the interface rather than
|
||||||
/// surfacing the error. Set far above any plausible completion — the copy this
|
/// 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.
|
/// 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;
|
const READBACK_POLL_LIMIT: u32 = 100_000;
|
||||||
|
|
||||||
/// Runs composed operation chains against demosaiced images.
|
/// Runs composed operation chains against demosaiced images.
|
||||||
@@ -323,6 +326,33 @@ impl AdjustPass {
|
|||||||
/// keeps it out of a shipping build.
|
/// keeps it out of a shipping build.
|
||||||
#[cfg(any(test, feature = "readback"))]
|
#[cfg(any(test, feature = "readback"))]
|
||||||
pub fn read_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
pub fn read_output(&self) -> Result<(Vec<u8>, 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<u8>, u32, u32), GpuError> {
|
||||||
|
self.copy_output()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transfer itself, shared by both readers above.
|
||||||
|
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||||
let Some(target) = self.target.as_ref() else {
|
let Some(target) = self.target.as_ref() else {
|
||||||
return Err(GpuError::Readback("nothing rendered yet".into()));
|
return Err(GpuError::Readback("nothing rendered yet".into()));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ pub mod settings;
|
|||||||
|
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, OutputSharpening,
|
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, ExportTarget,
|
||||||
Settings, SizingMode,
|
OutputSharpening, Settings, SizingMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Identifies a granted library location — a directory on Linux, a persisted
|
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||||
|
|||||||
@@ -166,11 +166,21 @@ pub struct ExportSettings {
|
|||||||
/// a user who wanted coordinates and has to re-export — costs a minute.
|
/// a user who wanted coordinates and has to re-export — costs a minute.
|
||||||
pub strip_location: bool,
|
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".
|
/// Destination folder. Empty means "ask each time".
|
||||||
///
|
///
|
||||||
/// Empty rather than a guessed `~/Pictures`: a silent default destination
|
/// Empty rather than a guessed `~/Pictures`: a silent default destination
|
||||||
/// is how exports end up somewhere the user never looks, and this is the
|
/// 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.
|
/// 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,
|
pub destination: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +199,65 @@ impl Default for ExportSettings {
|
|||||||
filename_template: "{name}".to_string(),
|
filename_template: "{name}".to_string(),
|
||||||
collision: CollisionPolicy::Increment,
|
collision: CollisionPolicy::Increment,
|
||||||
strip_location: true,
|
strip_location: true,
|
||||||
|
target: ExportTarget::Device,
|
||||||
destination: String::new(),
|
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).
|
/// Output container and codec (FR-EXP-1).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -436,6 +500,14 @@ impl Settings {
|
|||||||
if self.export.filename_template.trim().is_empty() {
|
if self.export.filename_template.trim().is_empty() {
|
||||||
self.export.filename_template = "{name}".to_string();
|
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);
|
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::<Settings>(&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]
|
#[test]
|
||||||
fn sanitise_clamps_an_out_of_range_quality() {
|
fn sanitise_clamps_an_out_of_range_quality() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings::default();
|
||||||
|
|||||||
+10
-10
@@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 102 |
|
| Source files scanned | 109 |
|
||||||
| TRACES tags found | 145 |
|
| TRACES tags found | 154 |
|
||||||
| Requirements defined | 151 |
|
| Requirements defined | 151 |
|
||||||
| Requirements covered | 73 |
|
| Requirements covered | 73 |
|
||||||
| **Coverage** | **48.3%** (73/151) |
|
| **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-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-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-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-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-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 | [`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-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-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 | [`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) |
|
| 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-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-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) |
|
| 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-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-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-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) |
|
| 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-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-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-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) |
|
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use dr_types::settings::budget;
|
use dr_types::settings::budget;
|
||||||
use dr_types::{
|
use dr_types::{
|
||||||
CollisionPolicy, ColourSpace, ExportFormat, OutputSharpening, Settings, SizingMode,
|
CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, OutputSharpening, Settings,
|
||||||
|
SizingMode,
|
||||||
};
|
};
|
||||||
use slint::ComponentHandle;
|
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_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
|
||||||
window.set_settings_strip_location(s.export.strip_location);
|
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());
|
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());
|
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 ---------------------------------------------------------
|
// --- reset ---------------------------------------------------------
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|||||||
@@ -525,6 +525,9 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> settings-collision-selected: 0;
|
in property <int> settings-collision-selected: 0;
|
||||||
in property <bool> settings-strip-location: true;
|
in property <bool> settings-strip-location: true;
|
||||||
in property <string> settings-destination: "";
|
in property <string> settings-destination: "";
|
||||||
|
in property <string> settings-destination-hint;
|
||||||
|
in property <[string]> settings-target-labels;
|
||||||
|
in property <int> settings-target-selected: 0;
|
||||||
in property <string> settings-error: "";
|
in property <string> settings-error: "";
|
||||||
|
|
||||||
callback settings-format-picked(int);
|
callback settings-format-picked(int);
|
||||||
@@ -538,6 +541,7 @@ export component AppWindow inherits Window {
|
|||||||
callback settings-collision-picked(int);
|
callback settings-collision-picked(int);
|
||||||
callback settings-strip-location-toggled(bool);
|
callback settings-strip-location-toggled(bool);
|
||||||
callback settings-destination-changed(string);
|
callback settings-destination-changed(string);
|
||||||
|
callback settings-target-changed(int);
|
||||||
callback settings-reset();
|
callback settings-reset();
|
||||||
|
|
||||||
/// Show the settings page. Reads the file first, so a second instance's
|
/// 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;
|
collision-selected: root.settings-collision-selected;
|
||||||
strip-location: root.settings-strip-location;
|
strip-location: root.settings-strip-location;
|
||||||
destination: root.settings-destination;
|
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;
|
error: root.settings-error;
|
||||||
|
|
||||||
format-picked(i) => { root.settings-format-picked(i); }
|
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); }
|
collision-picked(i) => { root.settings-collision-picked(i); }
|
||||||
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
||||||
destination-changed(t) => { root.settings-destination-changed(t); }
|
destination-changed(t) => { root.settings-destination-changed(t); }
|
||||||
|
target-picked(i) => { root.settings-target-changed(i); }
|
||||||
|
|
||||||
activity-rows: root.activity-rows;
|
activity-rows: root.activity-rows;
|
||||||
activity-running: root.activity-running;
|
activity-running: root.activity-running;
|
||||||
|
|||||||
@@ -325,6 +325,11 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in property <int> collision-selected: 0;
|
in property <int> collision-selected: 0;
|
||||||
in property <bool> strip-location: true;
|
in property <bool> strip-location: true;
|
||||||
in-out property <string> destination;
|
in-out property <string> 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 <string> destination-hint;
|
||||||
|
in property <[string]> target-labels;
|
||||||
|
in property <int> target-selected: 0;
|
||||||
|
|
||||||
callback format-picked(int);
|
callback format-picked(int);
|
||||||
callback quality-changed(int);
|
callback quality-changed(int);
|
||||||
@@ -337,6 +342,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
callback collision-picked(int);
|
callback collision-picked(int);
|
||||||
callback strip-location-toggled(bool);
|
callback strip-location-toggled(bool);
|
||||||
callback destination-changed(string);
|
callback destination-changed(string);
|
||||||
|
callback target-picked(int);
|
||||||
|
|
||||||
/// A save failed. The page's whole contract is that what it shows is
|
/// A save failed. The page's whole contract is that what it shows is
|
||||||
/// stored, so this cannot be swallowed.
|
/// stored, so this cannot be swallowed.
|
||||||
@@ -684,12 +690,24 @@ export component SettingsPage inherits Rectangle {
|
|||||||
picked(i) => { root.collision-picked(i); }
|
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 {
|
EntryRow {
|
||||||
label: "Destination";
|
label: "Destination";
|
||||||
hint: "empty asks each time";
|
hint: "empty asks each time";
|
||||||
text <=> root.destination;
|
text <=> root.destination;
|
||||||
field-width: 320px;
|
field-width: 320px;
|
||||||
placeholder: "Ask each time";
|
placeholder: root.destination-hint;
|
||||||
accepted(t) => { root.destination-changed(t); }
|
accepted(t) => { root.destination-changed(t); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user