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>
171 lines
7.0 KiB
TOML
171 lines
7.0 KiB
TOML
[workspace]
|
||
resolver = "2"
|
||
members = [
|
||
"core/dr-types",
|
||
"core/dr-catalog",
|
||
"core/dr-thumbs",
|
||
"core/dr-decode",
|
||
"core/dr-export",
|
||
"core/dr-gpu",
|
||
"core/dr-lens",
|
||
"core/dr-pipeline",
|
||
"core/dr-sync",
|
||
"core/dr-sync-nextcloud",
|
||
"platform/dr-plat",
|
||
"ui/dr-ui",
|
||
"apps/darkroom-desktop",
|
||
"apps/darkroom-android",
|
||
"tools/traceability",
|
||
]
|
||
|
||
[workspace.package]
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
rust-version = "1.92"
|
||
license = "GPL-3.0-or-later"
|
||
repository = "https://github.com/dtourolle/DarkRoom"
|
||
|
||
[workspace.dependencies]
|
||
# Internal
|
||
dr-types = { path = "core/dr-types" }
|
||
dr-catalog = { path = "core/dr-catalog" }
|
||
dr-thumbs = { path = "core/dr-thumbs" }
|
||
dr-decode = { path = "core/dr-decode" }
|
||
dr-export = { path = "core/dr-export" }
|
||
dr-gpu = { path = "core/dr-gpu" }
|
||
dr-lens = { path = "core/dr-lens" }
|
||
dr-pipeline = { path = "core/dr-pipeline" }
|
||
dr-plat = { path = "platform/dr-plat" }
|
||
dr-sync = { path = "core/dr-sync" }
|
||
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
|
||
dr-ui = { path = "ui/dr-ui" }
|
||
|
||
# GPU + UI
|
||
#
|
||
# The wgpu version is not a free choice: it is dictated by Slint. Importing a
|
||
# texture into the scene (ARCH §6.1, spike S1) requires it to come from the
|
||
# *same* `wgpu::Device` Slint renders with, and Slint will only hand out a
|
||
# device of the version it was compiled against. Slint 1.17 offers
|
||
# `unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so 29 it is —
|
||
# pinned to the same `29.0.4` floor Slint itself requires, because two
|
||
# semver-compatible-but-different wgpu crates in one tree are two *types*, and
|
||
# the device would not typecheck across them.
|
||
#
|
||
# Consequently: bumping Slint may force a wgpu bump, and wgpu cannot be bumped
|
||
# on its own. They move together or not at all.
|
||
wgpu = "29.0.4"
|
||
slint = { version = "1.17", default-features = false }
|
||
slint-build = "1.17"
|
||
|
||
# UI token codegen (S2): style.yaml -> theme.slint. serde_yaml was deprecated
|
||
# by its maintainer in 2024 and serde_yml, the first fork, has since been
|
||
# deprecated too; serde_norway is the fork still receiving releases. Its
|
||
# mappings preserve insertion order, which is what lets the generated Slint
|
||
# keep the token ordering the YAML author chose.
|
||
serde_norway = "0.9"
|
||
|
||
# Foundations
|
||
anyhow = "1"
|
||
thiserror = "2"
|
||
log = "0.4"
|
||
env_logger = "0.11"
|
||
pollster = "0.4"
|
||
|
||
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
|
||
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
|
||
# `rustls-no-provider` rather than `rustls`: the latter defaults to the
|
||
# aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to
|
||
# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
|
||
# ring is pure Rust apart from a small asm core that does build under the NDK.
|
||
#
|
||
# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`:
|
||
# the latter verifies against rustls-platform-verifier, which reaches the
|
||
# Android trust store over JNI and panics mid-handshake unless initialised from
|
||
# Java first — the crash D7 predicted and spike S3 exists to resolve properly.
|
||
# The panic surfaces inside tokio, which catches task panics itself, so it
|
||
# reaches the UI as a worker that stopped rather than as an error.
|
||
#
|
||
# webpki-roots is the escape hatch D7 records: a root store compiled into the
|
||
# binary, no JNI, identical on both platforms. The trade is real and belongs in
|
||
# S3's scope — user-installed and enterprise CAs are not consulted, and the
|
||
# roots go stale with the release rather than with the OS.
|
||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "webpki-roots", "stream", "json"] }
|
||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||
quick-xml = "0.41"
|
||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||
url = "2.5"
|
||
async-trait = "0.1"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
base64 = "0.23"
|
||
|
||
# Platform secure storage: Secret Service on Linux, Keystore on Android
|
||
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
||
# keyring 4 restructured its features: `v1` is the default set and brings
|
||
# the zbus Secret Service backend, which is what GNOME Keyring and KWallet
|
||
# (via ksecretd) both speak.
|
||
keyring = { version = "4", features = ["v1"] }
|
||
|
||
# The Android half of the same project: a keyring-core CredentialStore backed
|
||
# by AndroidKeyStore AES-GCM over SharedPreferences (FR-PLAT-AND-1). It reads
|
||
# the JavaVM and Context from ndk-context, which android-activity populates
|
||
# before `android_main` runs, so no Kotlin shim of our own is needed.
|
||
#
|
||
# This is the keyring-core API, not the v1 `Entry` API the Linux path uses;
|
||
# the two impls are deliberately separate rather than sharing a code path.
|
||
android-native-keyring-store = "1.0.0"
|
||
keyring-core = "1"
|
||
|
||
# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the
|
||
# embedded previews rawler extracts.
|
||
# Catalog. `bundled` compiles SQLite from source rather than linking the
|
||
# system library — the same cross-compilation reasoning as the TLS choice
|
||
# above: no system dependency to satisfy under the Android NDK.
|
||
#
|
||
# `backup` is not optional in practice: it is what takes a consistent snapshot
|
||
# of a live WAL database for upload. A filesystem copy of `catalog.sqlite`
|
||
# while a `-wal` exists beside it uploads a torn file.
|
||
rusqlite = { version = "0.40", features = ["bundled", "backup"] }
|
||
|
||
rawler = "0.7"
|
||
zune-jpeg = "0.4.21"
|
||
# Thumbnails are stored encoded, not as raw RGBA: a 256px RGBA buffer is
|
||
# ~256 KB against ~20 KB as JPEG, and the store syncs to Nextcloud where that
|
||
# 13× is transfer cost on every client. Pure Rust, no C dependency — the same
|
||
# criterion behind the TLS and SQLite choices above.
|
||
jpeg-encoder = "0.7"
|
||
bytemuck = { version = "1", features = ["derive"] }
|
||
|
||
# Lens correction profiles. A pure-Rust port of Lensfun rather than a binding
|
||
# to the C library, for the same cross-compilation reason as the TLS and
|
||
# SQLite choices above: liblensfun would be a third C dependency to satisfy
|
||
# under the Android NDK.
|
||
#
|
||
# The database ships *inside* the crate — 56 XML files, gzipped at build time
|
||
# and decompressed on first lookup. That matters beyond convenience: Android
|
||
# gives us no filesystem path (ARCH §6.9), so a database loaded from a
|
||
# system directory would have nowhere to live there.
|
||
#
|
||
# Licence: LGPL-3.0-or-later, which upgrades cleanly into our GPLv3 (D8).
|
||
# The upstream Lensfun *database* is CC-BY-SA and is redistributed by the
|
||
# crate; attribution belongs in the about screen.
|
||
#
|
||
# Caveat worth remembering: this is a third-party port at 0.7.0, not upstream
|
||
# Lensfun. Verified working against the bundled database (interpolation
|
||
# between calibration points, and an unknown lens returning empty rather than
|
||
# panicking), but the pipeline talks to it through its own profile types so
|
||
# swapping it out is not a pipeline change.
|
||
lensfun = "0.7"
|
||
|
||
[profile.dev]
|
||
# Dependencies optimised even in dev builds — wgpu and image decoding are
|
||
# unusably slow otherwise, and they rarely need debugging.
|
||
opt-level = 0
|
||
|
||
[profile.dev.package."*"]
|
||
opt-level = 2
|
||
|
||
[profile.release]
|
||
lto = "thin"
|
||
codegen-units = 1
|