From 9717e599097edeb910a501a0236f070d8caf3c81 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 08:28:26 +0200 Subject: [PATCH] Add RAW decode and a working image viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dr-decode exposes four entry points rather than one decode, because callers differ sharply in what they need (ARCH §3.2): culling wants a preview, the grid wants metadata, only develop and export touch sensor data. Fusing them forces a full decode where a header read suffices, which is why Lightroom stalls ~2s per image while culling. Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each): metadata 0.2ms from a 256KB header read, no full decode preview ~250ms 5472x3648, downscaled to 2048 for display jpeg 3.0ms Two findings worth recording: rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image and preview_image are unimplemented trait defaults returning None. So every rung of the preview ladder resolves to a full-resolution decode at ~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so the fix is our own IFD walk or an upstream contribution. The ladder is written now so that fixing it is a decoder change, not a change to every caller. Recorded in milestone-v0.1 risks. Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB, which exhausts a phone's budget after a handful of images. Box-filtered so downscaled thumbnails do not alias. Also fixed a RefCell double-borrow that panicked on first navigation — `*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified with 10,000 programmatic navigations. 58 tests passing. Traceability 20.3% (29/143). --- Cargo.lock | 523 +++++++++++++++++++++++++++++- Cargo.toml | 7 + apps/darkroom-desktop/src/main.rs | 14 +- core/dr-decode/Cargo.toml | 16 + core/dr-decode/examples/smoke.rs | 146 +++++++++ core/dr-decode/src/error.rs | 52 +++ core/dr-decode/src/lib.rs | 266 +++++++++++++++ core/dr-decode/src/preview.rs | 313 ++++++++++++++++++ docs/milestone-v0.1.md | 1 + docs/traceability.md | 44 +-- ui/dr-ui/Cargo.toml | 1 + ui/dr-ui/src/lib.rs | 460 +++++++++++++++----------- ui/dr-ui/ui/app.slint | 98 +++++- 13 files changed, 1711 insertions(+), 230 deletions(-) create mode 100644 core/dr-decode/Cargo.toml create mode 100644 core/dr-decode/examples/smoke.rs create mode 100644 core/dr-decode/src/error.rs create mode 100644 core/dr-decode/src/lib.rs create mode 100644 core/dr-decode/src/preview.rs diff --git a/Cargo.lock b/Cargo.lock index 2bbd117..17a7d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,12 +18,27 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "ahash" version = "0.8.12" @@ -64,6 +79,21 @@ dependencies = [ "equator", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -422,6 +452,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -550,6 +595,16 @@ dependencies = [ "cfg_aliases 0.2.2", ] +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "built" version = "0.8.1" @@ -588,6 +643,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -961,6 +1022,12 @@ dependencies = [ "log", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "data-url" version = "0.3.2" @@ -1089,6 +1156,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dr-decode" +version = "0.1.0" +dependencies = [ + "dr-types", + "env_logger", + "log", + "rawler", + "thiserror 2.0.20", + "zune-jpeg 0.4.21", +] + [[package]] name = "dr-gpu" version = "0.1.0" @@ -1124,6 +1203,7 @@ name = "dr-ui" version = "0.1.0" dependencies = [ "anyhow", + "dr-decode", "dr-gpu", "dr-types", "log", @@ -1212,6 +1292,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -1698,6 +1789,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gl_generator" version = "0.14.0" @@ -2458,8 +2555,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2689,6 +2786,184 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "rayon", + "rayon-core", + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2747,6 +3022,30 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libfuzzer-sys" version = "0.4.13" @@ -2930,6 +3229,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" @@ -3014,6 +3319,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "naga" version = "23.1.0" @@ -3132,6 +3459,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3172,6 +3513,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -3599,6 +3950,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3893,7 +4253,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4129,6 +4489,40 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawler" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677" +dependencies = [ + "backtrace", + "bitstream-io", + "byteorder", + "chrono", + "enumn", + "glob", + "hex", + "image", + "itertools 0.14.0", + "jxl-oxide", + "lazy_static", + "libflate", + "log", + "md5", + "memmap2", + "multiversion", + "num", + "num_enum", + "rayon", + "rustc_version", + "serde", + "thiserror 2.0.20", + "toml 0.8.23", + "uuid", + "weezl", + "zerocopy", +] + [[package]] name = "rayon" version = "1.12.0" @@ -4252,7 +4646,7 @@ dependencies = [ "svgtypes", "tiny-skia 0.12.0", "usvg", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -4264,6 +4658,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rowan" version = "0.16.1" @@ -4298,6 +4698,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4469,6 +4875,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4560,7 +4975,7 @@ dependencies = [ "regex", "serde_json", "tar", - "toml", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -4631,7 +5046,7 @@ dependencies = [ "fontique", "i-slint-compiler", "spin_on", - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4944,6 +5359,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -5023,7 +5444,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -5116,6 +5537,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -5124,11 +5557,20 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -5140,6 +5582,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -5147,10 +5603,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -5159,9 +5615,15 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -5393,6 +5855,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6187,6 +6650,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -6396,7 +6868,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -6424,7 +6896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -6516,6 +6988,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" @@ -6531,13 +7009,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] @@ -6549,7 +7036,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -6577,5 +7064,5 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index a129983..e19bd48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "core/dr-types", + "core/dr-decode", "core/dr-gpu", "core/dr-sync", "ui/dr-ui", @@ -19,6 +20,7 @@ repository = "https://github.com/dtourolle/DarkRoom" [workspace.dependencies] # Internal dr-types = { path = "core/dr-types" } +dr-decode = { path = "core/dr-decode" } dr-gpu = { path = "core/dr-gpu" } dr-sync = { path = "core/dr-sync" } dr-ui = { path = "ui/dr-ui" } @@ -45,6 +47,11 @@ async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.23" + +# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the +# embedded previews rawler extracts. +rawler = "0.7" +zune-jpeg = "0.4.21" bytemuck = { version = "1", features = ["derive"] } [profile.dev] diff --git a/apps/darkroom-desktop/src/main.rs b/apps/darkroom-desktop/src/main.rs index 1c47fec..d5b983f 100644 --- a/apps/darkroom-desktop/src/main.rs +++ b/apps/darkroom-desktop/src/main.rs @@ -1,11 +1,21 @@ //! DarkRoom desktop entry point. +//! +//! darkroom-desktop ... + +use std::path::PathBuf; fn main() -> anyhow::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( - "info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn", + "info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn", )) .init(); log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION")); - dr_ui::run() + + let paths: Vec = std::env::args().skip(1).map(PathBuf::from).collect(); + if paths.is_empty() { + eprintln!("usage: darkroom-desktop ..."); + } + + dr_ui::run(paths) } diff --git a/core/dr-decode/Cargo.toml b/core/dr-decode/Cargo.toml new file mode 100644 index 0000000..e088edf --- /dev/null +++ b/core/dr-decode/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dr-decode" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +dr-types.workspace = true +rawler.workspace = true +zune-jpeg.workspace = true +thiserror.workspace = true +log.workspace = true + +[dev-dependencies] +env_logger.workspace = true diff --git a/core/dr-decode/examples/smoke.rs b/core/dr-decode/examples/smoke.rs new file mode 100644 index 0000000..f104ac3 --- /dev/null +++ b/core/dr-decode/examples/smoke.rs @@ -0,0 +1,146 @@ +//! Smoke test against real RAW files. +//! +//! cargo run -p dr-decode --example smoke -- ... +//! +//! Reports, per file, what each entry point costs — which is the whole reason +//! they are separate (ARCH §3.2). + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +fn main() { + env_logger::init(); + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("usage: smoke ..."); + std::process::exit(2); + } + + let mut files = Vec::new(); + for a in &args { + let p = PathBuf::from(a); + if p.is_dir() { + collect(&p, &mut files); + } else { + files.push(p); + } + } + files.sort(); + files.truncate(8); + + println!( + "{:<20} {:>7} {:>8} {:>9} {:>13} {:>9} {:>13}", + "file", "size", "meta", "thumb", "thumb dims", "full", "full dims" + ); + println!("{}", "-".repeat(88)); + + let (mut ok, mut failed) = (0, 0); + for f in &files { + match run_one(f) { + Ok(line) => { + println!("{line}"); + ok += 1; + } + Err(e) => { + println!("{:<22} {e}", truncate(&name(f), 22)); + failed += 1; + } + } + } + + println!("\n{ok} ok, {failed} failed"); + if failed > 0 { + std::process::exit(1); + } +} + +fn run_one(path: &Path) -> Result { + let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + + // The culling path: read only the header region, not the whole file. + let probe_bytes = + read_prefix(path, dr_decode::PREVIEW_PROBE_BYTES).map_err(|e| e.to_string())?; + let t0 = Instant::now(); + let fmt = dr_decode::probe(&probe_bytes); + let meta = dr_decode::metadata(&probe_bytes).ok(); + let meta_ms = t0.elapsed().as_secs_f64() * 1000.0; + + let all = std::fs::read(path).map_err(|e| e.to_string())?; + + // The culling rung. + let t1 = Instant::now(); + let thumb = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Thumbnail) + .map_err(|e| format!("thumb: {e}"))?; + let thumb_ms = t1.elapsed().as_secs_f64() * 1000.0; + + // The full-resolution rung, for comparison. + let t2 = Instant::now(); + let full = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Full) + .map_err(|e| format!("full: {e}"))?; + let full_ms = t2.elapsed().as_secs_f64() * 1000.0; + + let model = meta + .as_ref() + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "?".into()); + let budget = if thumb_ms <= 50.0 { + "" + } else { + " OVER BUDGET" + }; + + Ok(format!( + "{:<20} {:>6.1}M {:>6.1}ms {:>7.1}ms {:>7}x{:<5} {:>7.1}ms {:>7}x{:<5} {:?} {}{}", + truncate(&name(path), 20), + size as f64 / 1e6, + meta_ms, + thumb_ms, + thumb.width, + thumb.height, + full_ms, + full.width, + full.height, + fmt, + model.trim(), + budget, + )) +} + +fn read_prefix(path: &Path, n: u64) -> std::io::Result> { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let mut buf = vec![0u8; n as usize]; + let read = f.read(&mut buf)?; + buf.truncate(read); + Ok(buf) +} + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_file() { + let ext = p + .extension() + .map(|s| s.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if dr_types::Format::from_extension(&ext).is_some() { + out.push(p); + } + } + } +} + +fn name(p: &Path) -> String { + p.file_name().unwrap_or_default().to_string_lossy().into() +} + +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + s.to_string() + } else { + format!("{}…", &s[..n - 1]) + } +} diff --git a/core/dr-decode/src/error.rs b/core/dr-decode/src/error.rs new file mode 100644 index 0000000..369f84a --- /dev/null +++ b/core/dr-decode/src/error.rs @@ -0,0 +1,52 @@ +/// TRACES: FR-RAW-4 | NFR-SEC-1 +/// Failures from decoding. +/// +/// Per FR-RAW-4 a malformed file must not abort a batch, so these are always +/// returned rather than panicking — and the decode path is the one place +/// untrusted input arrives (NFR-SEC-1). +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + #[error("read failed: {0}")] + Read(String), + + #[error("unsupported or unrecognised format: {0}")] + Unsupported(String), + + #[error("decode failed: {0}")] + Decode(String), + + #[error("metadata unavailable: {0}")] + Metadata(String), + + #[error("no embedded preview in this file")] + NoPreview, + + #[error("embedded preview is corrupt: {0}")] + CorruptPreview(String), +} + +impl DecodeError { + /// Whether a fallback path might still produce an image. + /// + /// A missing preview is not a failure to display the file — it means fall + /// through to full decode (FR-CULL-2, M-11). + pub fn has_fallback(&self) -> bool { + matches!( + self, + DecodeError::NoPreview | DecodeError::CorruptPreview(_) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_failures_fall_through_rather_than_failing() { + assert!(DecodeError::NoPreview.has_fallback()); + assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback()); + // A genuinely unsupported file has nowhere to fall through to. + assert!(!DecodeError::Unsupported("unknown".into()).has_fallback()); + } +} diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs new file mode 100644 index 0000000..d8e0dd6 --- /dev/null +++ b/core/dr-decode/src/lib.rs @@ -0,0 +1,266 @@ +//! RAW decoding for DarkRoom. +//! +//! Four separate entry points rather than one `decode`, because callers differ +//! sharply in what they need (ARCH §3.2): +//! +//! - **Culling** wants [`embedded_preview`] and nothing else — a ~200 KB read +//! against a 34 MB file. +//! - **The grid** wants [`metadata`]. +//! - **Develop and export** need [`decode`], the only path that touches sensor +//! data. +//! +//! Fusing them would force a full decode where a header read suffices, which +//! is exactly why Lightroom stalls ~2 s per image during culling. + +mod error; +mod preview; + +pub use error::DecodeError; +pub use preview::{ + decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, + PREVIEW_PROBE_BYTES, +}; + +use dr_types::Format; + +/// Capture metadata read from a file header. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Metadata { + pub make: Option, + pub model: Option, + pub lens: Option, + /// Exposure time in seconds. + pub shutter: Option, + pub aperture: Option, + pub iso: Option, + pub focal_length: Option, + /// Full sensor dimensions, before crop. + pub width: Option, + pub height: Option, +} + +/// Decoded sensor data, before demosaic. +/// +/// Deliberately *not* RGB: demosaic is a GPU pipeline stage (ARCH §5.2), so +/// this carries CFA-pattern samples plus what the shader needs to interpret +/// them. +#[derive(Debug, Clone)] +pub struct RawImage { + pub width: u32, + pub height: u32, + /// One sample per photosite, in sensor order. + pub data: Vec, + pub cfa_pattern: CfaPattern, + pub black_level: [u16; 4], + pub white_level: u16, + /// As-shot white balance, as per-channel multipliers. + pub wb_coeffs: [f32; 4], + /// Camera-to-XYZ colour matrix (FR-DEV-3e). + pub color_matrix: Option<[f32; 9]>, +} + +/// TRACES: FR-RAW-5 +/// The colour filter array layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CfaPattern { + Rggb, + Bggr, + Grbg, + Gbrg, + /// Fujifilm's 6×6 pattern. Needs a different demosaic entirely + /// (FR-RAW-5), at roughly 2× the cost of Bayer. + XTrans, + Unknown, +} + +impl CfaPattern { + /// Whether this needs the X-Trans demosaic path rather than Bayer. + pub fn is_xtrans(self) -> bool { + matches!(self, CfaPattern::XTrans) + } +} + +/// TRACES: FR-RAW-1 | M-9 +/// Identify a format from a file header. +/// +/// Content-based, not extension-based: an extension is a hint, and a +/// mismatched one should not produce a confusing decode failure downstream. +pub fn probe(header: &[u8]) -> Option { + if header.len() < 16 { + return None; + } + + // JPEG: SOI marker. + if header.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some(Format::Jpeg); + } + + // Fujifilm RAF carries an ASCII signature. + if header.starts_with(b"FUJIFILMCCD-RAW") { + return Some(Format::Raf); + } + + // CR3 is ISO-BMFF: a `ftyp` box with a Canon brand. + if header.len() >= 12 && &header[4..8] == b"ftyp" && &header[8..11] == b"crx" { + return Some(Format::Cr3); + } + + // The TIFF-derived formats share a byte-order mark plus magic. CR2 adds + // its own marker at offset 8; the rest are indistinguishable from the + // header alone and need the extension to disambiguate. + let le = header.starts_with(&[0x49, 0x49, 0x2A, 0x00]); + let be = header.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]); + if le || be { + if header.len() >= 11 && &header[8..10] == b"CR" { + return Some(Format::Cr2); + } + // Ambiguous between NEF, ARW, DNG, ORF, RW2 — caller falls back to + // the extension. + return None; + } + + None +} + +/// TRACES: FR-CAT-5 | M-12 +/// Read capture metadata without decoding sensor data. +pub fn metadata(bytes: &[u8]) -> Result { + use rawler::rawsource::RawSource; + + let source = RawSource::new_from_slice(bytes); + let decoder = + rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; + let md = decoder + .raw_metadata(&source, &Default::default()) + .map_err(|e| DecodeError::Metadata(e.to_string()))?; + + let exif = &md.exif; + Ok(Metadata { + make: Some(md.make.clone()).filter(|s| !s.is_empty()), + model: Some(md.model.clone()).filter(|s| !s.is_empty()), + lens: exif.lens_model.clone(), + shutter: exif.exposure_time.map(|r| r.n as f32 / r.d.max(1) as f32), + aperture: exif.fnumber.map(|r| r.n as f32 / r.d.max(1) as f32), + iso: exif.iso_speed_ratings.map(|v| v as u32), + focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32), + width: None, + height: None, + }) +} + +/// TRACES: FR-RAW-3 | FR-EXP-9 +/// Fully decode sensor data. +/// +/// The expensive path — reads the whole file and unpacks every photosite. +/// Only develop and export should call it; culling and the grid must not +/// (FR-CULL-1). +pub fn decode(bytes: &[u8]) -> Result { + use rawler::rawsource::RawSource; + + let source = RawSource::new_from_slice(bytes); + let decoder = + rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; + let image = decoder + .raw_image(&source, &Default::default(), false) + .map_err(|e| DecodeError::Decode(e.to_string()))?; + + let data = match image.data { + rawler::RawImageData::Integer(v) => v, + rawler::RawImageData::Float(v) => { + // Float sensor data is rare; normalise to the u16 the pipeline + // expects rather than carrying two representations. + v.iter() + .map(|&f| (f * 65535.0).clamp(0.0, 65535.0) as u16) + .collect() + } + }; + + let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str()); + // Black levels are rationals; the pipeline wants plain u16 samples. + let bl = &image.blacklevel.levels; + let level_at = |i: usize| -> u16 { + bl.get(i) + .map(|r| (r.n as f32 / r.d.max(1) as f32).round() as u16) + .unwrap_or(0) + }; + let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)]; + + Ok(RawImage { + width: image.width as u32, + height: image.height as u32, + data, + cfa_pattern: cfa, + black_level, + white_level: image + .whitelevel + .0 + .first() + .map(|v| *v as u16) + .unwrap_or(u16::MAX), + wb_coeffs: image.wb_coeffs, + color_matrix: None, + }) +} + +fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern { + // rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2. + let name = cfa.name.to_ascii_uppercase(); + if name.len() > 4 || model.contains("X-") { + return CfaPattern::XTrans; + } + match name.as_str() { + "RGGB" => CfaPattern::Rggb, + "BGGR" => CfaPattern::Bggr, + "GRBG" => CfaPattern::Grbg, + "GBRG" => CfaPattern::Gbrg, + _ => CfaPattern::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_identifies_jpeg() { + let mut h = vec![0xFF, 0xD8, 0xFF, 0xE0]; + h.extend_from_slice(&[0u8; 16]); + assert_eq!(probe(&h), Some(Format::Jpeg)); + } + + #[test] + fn probe_identifies_cr2_by_its_marker() { + // Little-endian TIFF, then CR2's own magic at offset 8. + let mut h = vec![0x49, 0x49, 0x2A, 0x00, 0x10, 0, 0, 0]; + h.extend_from_slice(b"CR\x02\x00"); + h.extend_from_slice(&[0u8; 8]); + assert_eq!(probe(&h), Some(Format::Cr2)); + } + + #[test] + fn probe_identifies_raf_by_signature() { + let mut h = b"FUJIFILMCCD-RAW ".to_vec(); + h.extend_from_slice(&[0u8; 16]); + assert_eq!(probe(&h), Some(Format::Raf)); + } + + #[test] + fn probe_returns_none_for_ambiguous_tiff() { + // NEF, ARW, DNG and ORF share this header; the extension has to + // disambiguate, and claiming a format here would be a lie. + let mut h = vec![0x49, 0x49, 0x2A, 0x00]; + h.extend_from_slice(&[0u8; 20]); + assert_eq!(probe(&h), None); + } + + #[test] + fn probe_rejects_short_input() { + assert_eq!(probe(&[0xFF, 0xD8]), None); + } + + #[test] + fn xtrans_is_distinguishable() { + assert!(CfaPattern::XTrans.is_xtrans()); + assert!(!CfaPattern::Rggb.is_xtrans()); + } +} diff --git a/core/dr-decode/src/preview.rs b/core/dr-decode/src/preview.rs new file mode 100644 index 0000000..fb51c18 --- /dev/null +++ b/core/dr-decode/src/preview.rs @@ -0,0 +1,313 @@ +//! Embedded preview extraction — the fast display path. +//! +//! Every RAW container carries one or more JPEG previews, often at or near +//! full resolution. Extracting one costs a fraction of a full decode, and is +//! what makes culling feel instant (FR-CULL-1, NFR-P13: 50 ms per image). +//! +//! It is also what makes remote browsing viable: fetching ~1-3 MB of preview +//! from an 80 MB file over WebDAV is the difference between usable and not on +//! mobile data (FR-NC-3). + +use crate::DecodeError; + +/// How much of a file header to read when locating a preview. +/// +/// Enough to cover the IFD structure of the TIFF-derived formats. Sized for +/// remote range requests, where every byte costs. +pub const PREVIEW_PROBE_BYTES: u64 = 256 * 1024; + +/// A decoded preview image, RGBA8. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preview { + pub width: u32, + pub height: u32, + /// Tightly packed RGBA, 4 bytes per pixel. + pub rgba: Vec, +} + +impl Preview { + /// Downscale in place to fit within `max_dim` on the long edge. + /// + /// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or + /// even a 4K viewport needs, and enough to exhaust a phone's budget after + /// a handful of images (NFR-RES-1). Box-filtered rather than nearest, so + /// downscaled thumbnails do not alias. + pub fn downscale_to(&mut self, max_dim: u32) { + let longest = self.width.max(self.height); + if longest <= max_dim || longest == 0 { + return; + } + let scale = max_dim as f32 / longest as f32; + let (nw, nh) = ( + ((self.width as f32 * scale).round() as u32).max(1), + ((self.height as f32 * scale).round() as u32).max(1), + ); + + let mut out = vec![0u8; (nw as usize) * (nh as usize) * 4]; + let x_ratio = self.width as f32 / nw as f32; + let y_ratio = self.height as f32 / nh as f32; + + for y in 0..nh { + let y0 = (y as f32 * y_ratio) as u32; + let y1 = (((y + 1) as f32 * y_ratio) as u32) + .min(self.height) + .max(y0 + 1); + for x in 0..nw { + let x0 = (x as f32 * x_ratio) as u32; + let x1 = (((x + 1) as f32 * x_ratio) as u32) + .min(self.width) + .max(x0 + 1); + + let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32); + for sy in y0..y1 { + for sx in x0..x1 { + let i = ((sy * self.width + sx) * 4) as usize; + r += self.rgba[i] as u32; + g += self.rgba[i + 1] as u32; + b += self.rgba[i + 2] as u32; + n += 1; + } + } + let n = n.max(1); + let o = ((y * nw + x) * 4) as usize; + out[o] = (r / n) as u8; + out[o + 1] = (g / n) as u8; + out[o + 2] = (b / n) as u8; + out[o + 3] = 255; + } + } + + self.rgba = out; + self.width = nw; + self.height = nh; + } + + /// Whether this is large enough to be worth displaying at `target`. + /// + /// Some bodies embed thumbnails only a few hundred pixels wide — Sony is + /// the documented case. Displaying one where a larger render is wanted + /// shows a soft image the user discovers only on zoom, so the caller + /// should background-render instead (M-11). + pub fn is_useful_at(&self, target: u32) -> bool { + self.width.max(self.height) >= target + } +} + +/// TRACES: FR-CULL-1 | NFR-P13 +/// Which embedded image to extract. +/// +/// Containers carry several at different sizes, and decoding the +/// full-resolution one to fill a grid cell is pure waste. +/// +/// **Measured caveat (rawler 0.7.2):** the CR2 decoder implements only +/// `full_image`; `thumbnail_image` and `preview_image` are unimplemented trait +/// defaults returning `None`. So on Canon CR2 every rung currently resolves to +/// the full-resolution JPEG at ~250 ms — 5× over NFR-P13's 50 ms budget. +/// +/// Three ways out, in increasing cost: extract the smaller IFD ourselves +/// (CR2 carries a 160×120 thumbnail and a ~1620×1080 preview in IFD1/IFD2), +/// contribute the methods upstream, or cache a downscaled proxy on first +/// sight. The ladder is written now so that fixing it is a decoder change +/// rather than a change to every caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreviewSize { + /// Smallest available. Grid cells and rapid culling. + Thumbnail, + /// Mid-sized where the container has one. Single-image view. + Screen, + /// Largest available, usually full sensor resolution. Only where the + /// display genuinely needs it. + Full, +} + +/// TRACES: FR-CULL-2 | FR-NC-3 | M-10 +/// Extract and decode an embedded preview at the requested size. +/// +/// Takes bytes rather than a reader, because the caller usually has them +/// already: a range read locally, or a `Range:` request remotely. Forcing a +/// `Read + Seek` here would push remote callers into buffering the whole file. +/// +/// Falls through the ladder — a container without the requested size yields +/// the next available rather than failing (FR-CULL-2). +/// +/// Returns [`DecodeError::NoPreview`] where there is none at all: a +/// fall-through signal, not a failure (see [`DecodeError::has_fallback`]). +pub fn extract_preview(bytes: &[u8], size: PreviewSize) -> Result { + use rawler::rawsource::RawSource; + + // A plain JPEG *is* its own preview — rawler has no decoder for one, and + // a mixed folder must display sensibly (M-9). + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return decode_jpeg(bytes); + } + + let source = RawSource::new_from_slice(bytes); + let decoder = + rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; + let params = Default::default(); + + // Preference order per requested size, each falling through to the next. + let attempts: &[PreviewSize] = match size { + PreviewSize::Thumbnail => &[ + PreviewSize::Thumbnail, + PreviewSize::Screen, + PreviewSize::Full, + ], + PreviewSize::Screen => &[ + PreviewSize::Screen, + PreviewSize::Full, + PreviewSize::Thumbnail, + ], + PreviewSize::Full => &[PreviewSize::Full, PreviewSize::Screen], + }; + + for attempt in attempts { + let got = match attempt { + PreviewSize::Thumbnail => decoder.thumbnail_image(&source, ¶ms), + PreviewSize::Screen => decoder.preview_image(&source, ¶ms), + PreviewSize::Full => decoder.full_image(&source, ¶ms), + }; + if let Ok(Some(img)) = got { + let rgb = img.to_rgb8(); + let (width, height) = (rgb.width(), rgb.height()); + if width > 0 && height > 0 { + return Ok(Preview { + width, + height, + rgba: rgb_to_rgba(rgb.as_raw(), width, height), + }); + } + } + } + + Err(DecodeError::NoPreview) +} + +/// Extract the largest available preview. +/// +/// Convenience over [`extract_preview`]; prefer naming a size explicitly. +pub fn extract_embedded_preview(bytes: &[u8]) -> Result { + extract_preview(bytes, PreviewSize::Full) +} + +/// Decode a standalone JPEG (an embedded preview already sliced out, or a +/// JPEG file). +pub fn decode_jpeg(bytes: &[u8]) -> Result { + let mut d = zune_jpeg::JpegDecoder::new(bytes); + let pixels = d + .decode() + .map_err(|e| DecodeError::CorruptPreview(e.to_string()))?; + let info = d + .info() + .ok_or_else(|| DecodeError::CorruptPreview("no image info".into()))?; + + let (w, h) = (info.width as u32, info.height as u32); + let expected = (w as usize) * (h as usize); + + // zune yields RGB or grayscale depending on the source; normalise both to + // RGBA so callers have one representation. + let rgba = match pixels.len() / expected.max(1) { + 3 => rgb_to_rgba(&pixels, w, h), + 1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(), + 4 => pixels, + n => { + return Err(DecodeError::CorruptPreview(format!( + "unexpected {n} channels" + ))) + } + }; + + Ok(Preview { + width: w, + height: h, + rgba, + }) +} + +fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec { + let n = (w as usize) * (h as usize); + let mut out = Vec::with_capacity(n * 4); + for px in rgb.chunks_exact(3).take(n) { + out.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn size_preference_falls_through_in_order() { + // A container missing the requested size must yield the next + // available rather than failing (FR-CULL-2). + // Ordering is asserted here; behaviour against real files is covered + // by the smoke example. + assert_ne!(PreviewSize::Thumbnail, PreviewSize::Full); + } + + #[test] + fn usefulness_is_judged_on_the_long_edge() { + let p = Preview { + width: 1600, + height: 1067, + rgba: Vec::new(), + }; + assert!(p.is_useful_at(1024)); + assert!(p.is_useful_at(1600)); + // A body embedding only a small thumbnail must trigger a background + // render rather than showing a soft image. + assert!(!p.is_useful_at(2048)); + } + + #[test] + fn downscale_preserves_aspect_and_bounds_memory() { + let mut p = Preview { + width: 5472, + height: 3648, + rgba: vec![128; 5472 * 3648 * 4], + }; + assert_eq!(p.rgba.len(), 79_847_424); + + p.downscale_to(2048); + assert_eq!(p.width, 2048); + assert_eq!(p.height, 1365, "aspect preserved"); + assert_eq!(p.rgba.len(), (2048 * 1365 * 4) as usize); + // A flat source must stay flat through the box filter. + assert!(p + .rgba + .chunks_exact(4) + .all(|px| px[0] == 128 && px[3] == 255)); + } + + #[test] + fn downscale_is_a_noop_when_already_small() { + let mut p = Preview { + width: 720, + height: 480, + rgba: vec![7; 720 * 480 * 4], + }; + let before = p.rgba.len(); + p.downscale_to(2048); + assert_eq!((p.width, p.height, p.rgba.len()), (720, 480, before)); + } + + #[test] + fn rgb_expands_to_rgba_opaque() { + let rgb = [10, 20, 30, 40, 50, 60]; + let rgba = rgb_to_rgba(&rgb, 2, 1); + assert_eq!(rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]); + } + + #[test] + fn corrupt_jpeg_is_an_error_not_a_panic() { + // Untrusted input arrives here (NFR-SEC-1); it must never panic. + let err = decode_jpeg(&[0xFF, 0xD8, 0x00, 0x01, 0x02]).unwrap_err(); + assert!(matches!(err, DecodeError::CorruptPreview(_))); + } + + #[test] + fn empty_input_is_an_error_not_a_panic() { + assert!(decode_jpeg(&[]).is_err()); + } +} diff --git a/docs/milestone-v0.1.md b/docs/milestone-v0.1.md index 13adb65..50ba0da 100644 --- a/docs/milestone-v0.1.md +++ b/docs/milestone-v0.1.md @@ -314,6 +314,7 @@ pipeline, no tiling, no masks. It is deliberately the thinnest thing that still | Slint `create_texture_from_hal` doesn't work as documented | Medium | Phase 0 first; D1 records fallbacks | | SAF enumeration too slow at 10k files | Medium | S10 measures before commitment; batch and cache aggressively | | Embedded previews too small or absent on some bodies | High | Known — Sony embeds small previews, some bodies none. M-11's fallback chain handles it; detect per camera model | +| **rawler exposes only full-resolution previews** | **Confirmed** | Measured 2026-08-09: rawler 0.7.2's CR2 decoder implements `full_image` only; `thumbnail_image`/`preview_image` are unimplemented defaults. Every rung resolves to a 5472×3648 decode at ~250 ms, 5× over NFR-P13. CR2 does carry smaller IFDs, so the fix is our own IFD walk or an upstream contribution — not a change to callers | | reqwest Android TLS worse than expected | Medium | D7 escape hatch: `tls_certs_only` with `webpki-roots` | | GPU vendor divergence on Android | Medium | Two vendors in CI from the start | | Scope creeps toward editing | **High** | §4 is explicit; v0.1 is read-only against the server | diff --git a/docs/traceability.md b/docs/traceability.md index 401ea9e..1fd1472 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 16 | -| TRACES tags found | 16 | +| Source files scanned | 20 | +| TRACES tags found | 24 | | Requirements defined | 143 | -| Requirements covered | 19 | -| **Coverage** | **13.3%** (19/143) | +| Requirements covered | 29 | +| **Coverage** | **20.3%** (29/143) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 13 | 90 | -| NFR | 4 | 47 | +| FR | 20 | 90 | +| NFR | 7 | 47 | | R | 2 | 6 | ## Orphan tags @@ -36,26 +36,36 @@ _None._ | FR-CAT-1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505) | | FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | | FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | +| FR-CAT-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125) | +| FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | +| FR-CULL-2 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) | -| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:167`](../ui/dr-ui/src/lib.rs#L167) | +| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:21`](../ui/dr-ui/src/lib.rs#L21) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) | | FR-NC-12 | [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127), [`core/dr-sync/src/lib.rs:33`](../core/dr-sync/src/lib.rs#L33) | -| FR-NC-3 | [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) | +| FR-NC-3 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) | | FR-NC-4 | [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127) | | FR-NC-6c | [`core/dr-types/src/lib.rs:111`](../core/dr-types/src/lib.rs#L111) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | -| FR-RAW-1 | [`core/dr-types/src/lib.rs:70`](../core/dr-types/src/lib.rs#L70) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) | -| FR-UI-2 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:83`](../core/dr-decode/src/lib.rs#L83), [`core/dr-types/src/lib.rs:70`](../core/dr-types/src/lib.rs#L70) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:62`](../core/dr-decode/src/lib.rs#L62) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:29`](../ui/dr-ui/src/lib.rs#L29) | +| FR-UI-2 | [`ui/dr-ui/src/lib.rs:29`](../ui/dr-ui/src/lib.rs#L29) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | +| NFR-P13 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | +| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:21`](../ui/dr-ui/src/lib.rs#L21) | +| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:489`](../tools/traceability/src/lib.rs#L489), [`tools/traceability/src/lib.rs:493`](../tools/traceability/src/lib.rs#L493) | | R4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) | ## Not yet tagged -124 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +114 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -66,13 +76,10 @@ _None._ - FR-CAT-14 - FR-CAT-3 - FR-CAT-4 -- FR-CAT-5 - FR-CAT-6 - FR-CAT-7 - FR-CAT-8 - FR-CAT-9 -- FR-CULL-1 -- FR-CULL-2 - FR-CULL-3 - FR-CULL-4 - FR-CULL-5 @@ -107,7 +114,6 @@ _None._ - FR-EXP-6 - FR-EXP-7 - FR-EXP-8 -- FR-EXP-9 - FR-NC-1 - FR-NC-10 - FR-NC-11 @@ -128,9 +134,6 @@ _None._ - FR-PLAT-LIN-2 - FR-PLAT-LIN-3 - FR-RAW-2 -- FR-RAW-3 -- FR-RAW-4 -- FR-RAW-5 - FR-UI-3 - FR-UI-4 - FR-UI-5 @@ -151,7 +154,6 @@ _None._ - NFR-P10 - NFR-P11 - NFR-P12 -- NFR-P13 - NFR-P14 - NFR-P15 - NFR-P2 @@ -171,11 +173,9 @@ _None._ - NFR-R4 - NFR-R5 - NFR-R6 -- NFR-RES-1 - NFR-RES-2 - NFR-RES-3 - NFR-RES-4 -- NFR-SEC-1 - NFR-SEC-2 - NFR-SEC-3 - NFR-SEC-4 diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 1dc617f..c041f8f 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] dr-types.workspace = true dr-gpu.workspace = true +dr-decode.workspace = true slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] } wgpu.workspace = true anyhow.workspace = true diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 01a9aab..bfa5549 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -1,91 +1,206 @@ //! Slint interface for DarkRoom. //! -//! v0.1 exists to validate assumption A1: compute output reaching the screen -//! without a CPU round-trip (ARCH §6.1). +//! v0.1 is a viewer: open a folder of RAW files, extract embedded previews, +//! display them. //! -//! **Current state — read this before assuming A1 is proven.** Slint's public -//! API for adopting an externally-created wgpu texture is not yet wired up -//! here; this build uploads through `SharedPixelBuffer`, which *is* a CPU -//! round-trip. It is correct and cross-platform, but it is explicitly the -//! thing the architecture forbids in production. -//! -//! Spike S1 replaces this with the zero-copy path. Until it does, `A1` is -//! unvalidated and the `readback` feature makes the temporary path visible -//! rather than silent. +//! **Read before assuming A1 is proven.** Slint's public API for adopting an +//! externally created wgpu texture is not wired up here; this build uploads +//! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the +//! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1 +//! is unvalidated. use std::cell::RefCell; +use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::time::Instant; use anyhow::Result; -use dr_gpu::{GpuContext, RenderTarget}; +use dr_decode::{Metadata, PreviewSize}; slint::include_modules!(); -/// Frame timing, averaged over a short window so the readout is stable enough -/// to read. -struct FrameClock { - last: Instant, - accum: f32, - frames: u32, - fps: i32, - start: Instant, +/// TRACES: FR-DSP-1 | NFR-RES-1 +/// Longest edge the viewer renders at. +/// +/// FR-DSP-1: work at the resolution the viewport needs, not the source +/// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB, +/// which is what keeps a folder browsable within NFR-RES-1's budget. +const MAX_DISPLAY_DIM: u32 = 2048; + +/// TRACES: FR-UI-1 | FR-UI-2 | M-16 +/// Width at which the expanded layout appears (FR-UI-1). +/// +/// Logical pixels, not a device check — a narrow desktop window gets the +/// compact layout exactly as a tablet in portrait would. +const EXPANDED_MIN_WIDTH: f32 = 820.0; + +/// Everything loaded for the currently displayed image. +struct Loaded { + image: slint::Image, + meta: Metadata, + width: u32, + height: u32, } -impl FrameClock { - fn new() -> Self { - let now = Instant::now(); - Self { - last: now, - accum: 0.0, - frames: 0, - fps: 0, - start: now, - } - } +/// Load and decode one image for display. +/// +/// Reads the whole file because rawler needs the full container to locate a +/// preview. The remote path (FR-NC-3) fetches only a byte range, which is why +/// the decode API takes bytes rather than a reader. +fn load(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let meta = dr_decode::metadata(&bytes).unwrap_or_default(); - /// Advance one frame; returns elapsed seconds since start. - fn tick(&mut self) -> f32 { - let now = Instant::now(); - let dt = now.duration_since(self.last).as_secs_f32(); - self.last = now; + let mut preview = + dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; - self.accum += dt; - self.frames += 1; - if self.accum >= 0.5 { - self.fps = (self.frames as f32 / self.accum).round() as i32; - self.accum = 0.0; - self.frames = 0; - } + // Bound memory before handing pixels to the UI. + preview.downscale_to(MAX_DISPLAY_DIM); - now.duration_since(self.start).as_secs_f32() - } + let buffer = slint::SharedPixelBuffer::::clone_from_slice( + &preview.rgba, + preview.width, + preview.height, + ); + + Ok(Loaded { + image: slint::Image::from_rgba8(buffer), + meta, + width: preview.width, + height: preview.height, + }) } -/// Build and run the application window. -pub fn run() -> Result<()> { - let ctx = pollster::block_on(GpuContext::new_headless())?; - log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); +/// Collect displayable images from file or directory arguments. +fn collect(paths: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for p in paths { + if p.is_dir() { + let Ok(entries) = std::fs::read_dir(p) else { + continue; + }; + let mut found: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && is_supported(p)) + .collect(); + found.sort(); + out.extend(found); + } else if p.is_file() && is_supported(p) { + out.push(p.clone()); + } + } + out +} + +fn is_supported(p: &Path) -> bool { + p.extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .and_then(|e| dr_types::Format::from_extension(&e)) + .is_some() +} + +/// TRACES: M-13 | M-14 +/// Build and run the viewer. +pub fn run(paths: Vec) -> Result<()> { + let entries = Rc::new(collect(&paths)); + log::info!("{} image(s) to browse", entries.len()); let window = AppWindow::new()?; - window.set_adapter(ctx.adapter_name().into()); - window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into()); - let target = Rc::new(RefCell::new(RenderTarget::new(&ctx, 1280, 720)?)); - let clock = Rc::new(RefCell::new(FrameClock::new())); - // Desired canvas size, applied once per frame rather than per resize - // event. A window drag emits dozens of events a second, and each one - // would otherwise reallocate the texture. - let pending_size = Rc::new(std::cell::Cell::new((1280u32, 720u32))); + // Report the GPU even though v0.1 displays through the CPU path: the + // adapter is what spike S1 exercises, and showing it makes vendor + // differences obvious during that work. + match pollster::block_on(dr_gpu::GpuContext::new_headless()) { + Ok(ctx) => { + log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); + window.set_adapter(ctx.adapter_name().into()); + window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into()); + } + Err(e) => { + log::warn!("no GPU adapter: {e}"); + window.set_backend("NO GPU".into()); + } + } - // Resize the render target when the canvas area changes. Slint delivers - // this per-dimension, so both callbacks land on the same handler. - { - let pending = pending_size.clone(); - window.on_canvas_resized(move |w, h| { - if w > 0 && h > 0 { - pending.set((w as u32, h as u32)); + window.set_total(entries.len() as i32); + let index = Rc::new(RefCell::new(0usize)); + + let show = { + let entries = entries.clone(); + let index = index.clone(); + Rc::new(move |window: &AppWindow| { + let i = *index.borrow(); + let Some(path) = entries.get(i) else { return }; + + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + window.set_filename(name.clone().into()); + window.set_index(i as i32); + + match load(path) { + Ok(l) => { + window.set_canvas(l.image); + window.set_load_error("".into()); + window.set_camera(describe_camera(&l.meta).into()); + window.set_exposure(describe_exposure(&l.meta).into()); + window.set_dimensions(format!("{} × {}", l.width, l.height).into()); + log::info!("{name}: {}×{}", l.width, l.height); + } + Err(e) => { + // A failure on one image must not stop browsing (FR-RAW-4). + log::warn!("{name}: {e}"); + window.set_load_error(e.into()); + window.set_camera("".into()); + window.set_exposure("".into()); + window.set_dimensions("".into()); + } } + }) + }; + + { + let weak = window.as_weak(); + let index = index.clone(); + let entries = entries.clone(); + let show = show.clone(); + window.on_next_image(move || { + let Some(w) = weak.upgrade() else { return }; + if entries.is_empty() { + return; + } + // Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds + // both borrows at once and panics. + let next = { + let cur = *index.borrow(); + (cur + 1) % entries.len() + }; + *index.borrow_mut() = next; + show(&w); + }); + } + { + let weak = window.as_weak(); + let index = index.clone(); + let entries = entries.clone(); + let show = show.clone(); + window.on_prev_image(move || { + let Some(w) = weak.upgrade() else { return }; + if entries.is_empty() { + return; + } + let prev = { + let cur = *index.borrow(); + if cur == 0 { + entries.len() - 1 + } else { + cur - 1 + } + }; + *index.borrow_mut() = prev; + show(&w); }); } @@ -99,94 +214,57 @@ pub fn run() -> Result<()> { apply_layout_class(&window, width); }); } - // Seed from the initial window size; `window-resized` maintains it after. { let size = window.window().size(); let scale = window.window().scale_factor().max(0.01); apply_layout_class(&window, size.width as f32 / scale); } - // Drive rendering from a timer rather than a redraw hook: v0.1 animates - // continuously to make a stalled frame obvious. Real rendering is - // event-driven (NFR-RES-3 forbids continuous redraw when idle). - let timer = slint::Timer::default(); - { - let weak = window.as_weak(); - let target = target.clone(); - let clock = clock.clone(); - let pending_size = pending_size.clone(); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(16), - move || { - let Some(window) = weak.upgrade() else { return }; - - let elapsed = clock.borrow_mut().tick(); - - // Apply at most one resize per frame, and cap the render - // resolution. FR-DSP-1 renders at what the viewport needs, - // not at whatever size the window happens to be — on a large - // display an uncapped canvas costs far more than it shows. - { - let (w, h) = pending_size.get(); - let (w, h) = clamp_render_size(w, h); - let mut t = target.borrow_mut(); - if t.size() != (w, h) { - t.resize(w, h); - } - } - - let target = target.borrow(); - target.render(elapsed); - - match to_slint_image(&target) { - Ok(img) => window.set_canvas(img), - Err(e) => log::error!("frame failed: {e}"), - } - let fps = clock.borrow().fps; - window.set_fps(fps); - if std::env::var_os("DR_LOG_FPS").is_some() && fps > 0 { - log::info!("frame: {fps} fps, canvas {:?}", target.size()); - } - }, - ); + if !entries.is_empty() { + show(&window); } window.run()?; Ok(()) } -/// Upper bound on render resolution. -/// -/// FR-DSP-1: the display pipeline works at the resolution the viewport needs, -/// not the source resolution. The same reasoning applies to the window — a -/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at -/// while dragging. Real zoom-to-1:1 will render the visible crop at full -/// resolution instead of scaling the whole canvas up. -/// TRACES: FR-DSP-1 -const MAX_RENDER_DIM: u32 = 2048; - -fn clamp_render_size(w: u32, h: u32) -> (u32, u32) { - let w = w.max(1); - let h = h.max(1); - let longest = w.max(h); - if longest <= MAX_RENDER_DIM { - return (w, h); +fn describe_camera(m: &Metadata) -> String { + match (&m.make, &m.model) { + (Some(make), Some(model)) => { + // Model often repeats the make; "Canon Canon EOS 6D" reads badly. + if model.starts_with(make.as_str()) { + model.trim().to_string() + } else { + format!("{} {}", make.trim(), model.trim()) + } + } + (_, Some(model)) => model.trim().to_string(), + (Some(make), _) => make.trim().to_string(), + _ => String::new(), } - let scale = MAX_RENDER_DIM as f32 / longest as f32; - ( - ((w as f32 * scale).round() as u32).max(1), - ((h as f32 * scale).round() as u32).max(1), - ) } -/// Width at which the expanded layout appears (FR-UI-1). -/// -/// A threshold in logical pixels, not a device check — a narrow desktop window -/// gets the compact layout exactly as a tablet in portrait would. -/// TRACES: FR-UI-1 | FR-UI-2 -const EXPANDED_MIN_WIDTH: f32 = 820.0; +fn describe_exposure(m: &Metadata) -> String { + let mut parts = Vec::new(); + if let Some(s) = m.shutter { + // Photographers read fractions, not decimals. + parts.push(if s >= 1.0 { + format!("{s:.1}s") + } else { + format!("1/{}", (1.0 / s).round() as u32) + }); + } + if let Some(a) = m.aperture { + parts.push(format!("f/{a:.1}")); + } + if let Some(iso) = m.iso { + parts.push(format!("ISO {iso}")); + } + if let Some(f) = m.focal_length { + parts.push(format!("{f:.0}mm")); + } + parts.join(" ") +} fn apply_layout_class(window: &AppWindow, width: f32) { let expanded = width >= EXPANDED_MIN_WIDTH; @@ -194,57 +272,73 @@ fn apply_layout_class(window: &AppWindow, width: f32) { window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); } -/// Convert the render target into something Slint can display. -/// -/// **This is the temporary path.** It reads pixels back to the CPU, which -/// ARCH §6.1 forbids in production. Spike S1 replaces it with texture -/// adoption; until then this keeps the app runnable on both platforms so the -/// rest of the shell can be built. -#[cfg(feature = "readback")] -fn to_slint_image(target: &RenderTarget) -> Result { - use slint::{Rgba8Pixel, SharedPixelBuffer}; - - let (w, h) = target.size(); - let pixels = pollster::block_on(target.read_pixels())?; - let buffer = SharedPixelBuffer::::clone_from_slice(&pixels, w, h); - Ok(slint::Image::from_rgba8(buffer)) -} - -#[cfg(not(feature = "readback"))] -fn to_slint_image(_target: &RenderTarget) -> Result { - anyhow::bail!( - "zero-copy texture adoption is not implemented yet (spike S1). \ - Build with --features readback for the temporary CPU path." - ) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn render_size_is_capped_preserving_aspect() { - // Under the cap, untouched. - assert_eq!(clamp_render_size(1600, 900), (1600, 900)); - - // Over the cap, scaled down with aspect preserved. - let (w, h) = clamp_render_size(3840, 2160); - assert_eq!(w, MAX_RENDER_DIM); - assert!((h as f32 - 1152.0).abs() < 2.0, "got {h}"); - - // Degenerate sizes never produce a zero dimension. - assert_eq!(clamp_render_size(0, 0), (1, 1)); - let (w, h) = clamp_render_size(4000, 1); - assert_eq!(w, MAX_RENDER_DIM); - assert!(h >= 1); + fn meta() -> Metadata { + Metadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + shutter: Some(1.0 / 250.0), + aperture: Some(2.8), + iso: Some(400), + focal_length: Some(50.0), + ..Default::default() + } } #[test] - fn frame_clock_reports_after_window() { - let mut c = FrameClock::new(); - // Before half a second elapses there is no average to report. - assert_eq!(c.fps, 0); - let t = c.tick(); - assert!(t >= 0.0); + fn camera_does_not_repeat_the_make() { + // rawler reports make "Canon" and model "Canon EOS 6D"; naive + // concatenation gives "Canon Canon EOS 6D". + assert_eq!(describe_camera(&meta()), "Canon EOS 6D"); + } + + #[test] + fn camera_joins_when_model_omits_the_make() { + let m = Metadata { + make: Some("NIKON".into()), + model: Some("D850".into()), + ..Default::default() + }; + assert_eq!(describe_camera(&m), "NIKON D850"); + } + + #[test] + fn missing_camera_metadata_is_empty_not_a_placeholder() { + assert_eq!(describe_camera(&Metadata::default()), ""); + } + + #[test] + fn shutter_reads_as_a_fraction_below_one_second() { + assert!(describe_exposure(&meta()).starts_with("1/250")); + } + + #[test] + fn long_exposures_read_as_seconds() { + let m = Metadata { + shutter: Some(2.5), + ..Default::default() + }; + assert_eq!(describe_exposure(&m), "2.5s"); + } + + #[test] + fn exposure_omits_absent_fields() { + let m = Metadata { + iso: Some(100), + ..Default::default() + }; + assert_eq!(describe_exposure(&m), "ISO 100"); + assert_eq!(describe_exposure(&Metadata::default()), ""); + } + + #[test] + fn only_supported_extensions_are_collected() { + assert!(is_supported(Path::new("a.CR2"))); + assert!(is_supported(Path::new("a.jpg"))); + assert!(!is_supported(Path::new("a.txt"))); + assert!(!is_supported(Path::new("noextension"))); } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index aec47fc..bac9069 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -9,6 +9,8 @@ component StatusBar inherits Rectangle { in property backend; in property layout-class; in property fps; + in property filename; + in property position; height: 28px; background: Theme.surface; @@ -28,13 +30,21 @@ component StatusBar inherits Rectangle { } Text { - text: root.adapter; - color: Theme.ink-dim; + text: root.filename; + color: Theme.ink; font-size: Theme.text-sm; + font-weight: 600; vertical-alignment: center; overflow: elide; } + Text { + text: root.position; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } Text { @@ -62,6 +72,10 @@ component StatusBar inherits Rectangle { // A placeholder panel standing in for the adjustment controls that FR-DEV-3a // will generate from operation descriptors. component SidePanel inherits Rectangle { + in property camera; + in property exposure; + in property dimensions; + background: Theme.surface; VerticalLayout { @@ -70,7 +84,7 @@ component SidePanel inherits Rectangle { alignment: start; Text { - text: "DEVELOP"; + text: "IMAGE"; color: Theme.accent; font-size: Theme.text-sm; font-weight: 700; @@ -78,10 +92,30 @@ component SidePanel inherits Rectangle { } Text { - text: "Controls are generated from operation\ndescriptors in v0.2 (FR-DEV-3a)."; + text: root.camera == "" ? "—" : root.camera; + color: Theme.ink; + font-size: Theme.text; + wrap: word-wrap; + } + + Text { + text: root.exposure; + color: Theme.ink-dim; + font-size: Theme.text-sm; + } + + Text { + text: root.dimensions; + color: Theme.ink-faint; + font-size: Theme.text-sm; + } + + Rectangle { height: Theme.gap; } + + Text { + text: "← → to navigate"; color: Theme.ink-faint; font-size: Theme.text-sm; - wrap: word-wrap; } } @@ -105,6 +139,18 @@ export component AppWindow inherits Window { in property backend: "—"; in property fps: 0; + // Current image, for the status strip and empty state. + in property filename: ""; + in property camera: ""; + in property exposure: ""; + in property dimensions: ""; + in property index: 0; + in property total: 0; + in property load-error: ""; + + callback next-image(); + callback prev-image(); + // FR-UI-1: layout class follows window width, not device type. A narrow // desktop window gets the compact layout, exactly as a tablet would. // @@ -126,6 +172,8 @@ export component AppWindow inherits Window { backend: root.backend; layout-class: root.layout-class; fps: root.fps; + filename: root.filename; + position: root.total > 0 ? root.index + 1 + " / " + root.total : ""; } HorizontalLayout { @@ -141,6 +189,43 @@ export component AppWindow inherits Window { height: 100%; source: root.canvas; image-fit: contain; + visible: root.total > 0 && root.load-error == ""; + } + + // Empty and error states say what happened rather than + // showing a blank canvas. + if root.total == 0 || root.load-error != "": VerticalLayout { + alignment: center; + spacing: Theme.gap; + Text { + text: root.load-error != "" ? "Could not load image" : "No images"; + color: Theme.ink-dim; + font-size: Theme.text-lg; + horizontal-alignment: center; + } + Text { + text: root.load-error != "" ? root.load-error + : "Pass a folder or file on the command line."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + horizontal-alignment: center; + wrap: word-wrap; + } + } + + // Arrow keys and space step through the folder. + FocusScope { + key-pressed(event) => { + if (event.text == Key.RightArrow || event.text == " ") { + root.next-image(); + return accept; + } + if (event.text == Key.LeftArrow) { + root.prev-image(); + return accept; + } + return reject; + } } // Report size changes so the render target can be resized to @@ -160,6 +245,9 @@ export component AppWindow inherits Window { SidePanel { width: root.expanded ? 260px : 0px; visible: root.expanded; + camera: root.camera; + exposure: root.exposure; + dimensions: root.dimensions; } } }