Add RAW decode and a working image viewer
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).
This commit is contained in:
Generated
+505
-18
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
//! DarkRoom desktop entry point.
|
||||
//!
|
||||
//! darkroom-desktop <file-or-directory>...
|
||||
|
||||
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<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paths.is_empty() {
|
||||
eprintln!("usage: darkroom-desktop <file-or-directory>...");
|
||||
}
|
||||
|
||||
dr_ui::run(paths)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Smoke test against real RAW files.
|
||||
//!
|
||||
//! cargo run -p dr-decode --example smoke -- <file-or-dir>...
|
||||
//!
|
||||
//! 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<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: smoke <file-or-dir>...");
|
||||
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<String, String> {
|
||||
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<Vec<u8>> {
|
||||
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<PathBuf>) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub model: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
/// Exposure time in seconds.
|
||||
pub shutter: Option<f32>,
|
||||
pub aperture: Option<f32>,
|
||||
pub iso: Option<u32>,
|
||||
pub focal_length: Option<f32>,
|
||||
/// Full sensor dimensions, before crop.
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<u16>,
|
||||
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<Format> {
|
||||
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<Metadata, DecodeError> {
|
||||
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<RawImage, DecodeError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Preview, DecodeError> {
|
||||
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<Preview, DecodeError> {
|
||||
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<Preview, DecodeError> {
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
+22
-22
@@ -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.
|
||||
|
||||
<details><summary>Show untagged requirements</summary>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+277
-183
@@ -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<Loaded, String> {
|
||||
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::<slint::Rgba8Pixel>::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<PathBuf> {
|
||||
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<PathBuf> = 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<PathBuf>) -> 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<slint::Image> {
|
||||
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
||||
|
||||
let (w, h) = target.size();
|
||||
let pixels = pollster::block_on(target.read_pixels())?;
|
||||
let buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&pixels, w, h);
|
||||
Ok(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "readback"))]
|
||||
fn to_slint_image(_target: &RenderTarget) -> Result<slint::Image> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
+93
-5
@@ -9,6 +9,8 @@ component StatusBar inherits Rectangle {
|
||||
in property <string> backend;
|
||||
in property <string> layout-class;
|
||||
in property <int> fps;
|
||||
in property <string> filename;
|
||||
in property <string> 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 <string> camera;
|
||||
in property <string> exposure;
|
||||
in property <string> 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 <string> backend: "—";
|
||||
in property <int> fps: 0;
|
||||
|
||||
// Current image, for the status strip and empty state.
|
||||
in property <string> filename: "";
|
||||
in property <string> camera: "";
|
||||
in property <string> exposure: "";
|
||||
in property <string> dimensions: "";
|
||||
in property <int> index: 0;
|
||||
in property <int> total: 0;
|
||||
in property <string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user