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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "addr2line"
|
||||||
|
version = "0.25.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
|
||||||
|
dependencies = [
|
||||||
|
"gimli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler32"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -64,6 +79,21 @@ dependencies = [
|
|||||||
"equator",
|
"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]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -422,6 +452,21 @@ dependencies = [
|
|||||||
"arrayvec",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -550,6 +595,16 @@ dependencies = [
|
|||||||
"cfg_aliases 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -588,6 +643,12 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -961,6 +1022,12 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dary_heap"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1089,6 +1156,18 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
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]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1124,6 +1203,7 @@ name = "dr-ui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1212,6 +1292,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -1698,6 +1789,12 @@ dependencies = [
|
|||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gimli"
|
||||||
|
version = "0.32.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gl_generator"
|
name = "gl_generator"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -2458,8 +2555,8 @@ dependencies = [
|
|||||||
"rayon",
|
"rayon",
|
||||||
"rgb",
|
"rgb",
|
||||||
"tiff",
|
"tiff",
|
||||||
"zune-core",
|
"zune-core 0.5.3",
|
||||||
"zune-jpeg",
|
"zune-jpeg 0.5.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2689,6 +2786,184 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2747,6 +3022,30 @@ version = "0.2.189"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
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]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -2930,6 +3229,12 @@ dependencies = [
|
|||||||
"rayon",
|
"rayon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
@@ -3014,6 +3319,28 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "23.1.0"
|
version = "23.1.0"
|
||||||
@@ -3132,6 +3459,20 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
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]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@@ -3172,6 +3513,16 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-rational"
|
name = "num-rational"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -3599,6 +3950,15 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -3893,7 +4253,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.13+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4129,6 +4489,40 @@ dependencies = [
|
|||||||
"objc2-quartz-core 0.3.2",
|
"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]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -4252,7 +4646,7 @@ dependencies = [
|
|||||||
"svgtypes",
|
"svgtypes",
|
||||||
"tiny-skia 0.12.0",
|
"tiny-skia 0.12.0",
|
||||||
"usvg",
|
"usvg",
|
||||||
"zune-jpeg",
|
"zune-jpeg 0.5.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4264,6 +4658,12 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rle-decode-fast"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rowan"
|
name = "rowan"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -4298,6 +4698,12 @@ dependencies = [
|
|||||||
"unicode-width 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4469,6 +4875,15 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"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]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -4560,7 +4975,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tar",
|
"tar",
|
||||||
"toml",
|
"toml 1.1.4+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4631,7 +5046,7 @@ dependencies = [
|
|||||||
"fontique",
|
"fontique",
|
||||||
"i-slint-compiler",
|
"i-slint-compiler",
|
||||||
"spin_on",
|
"spin_on",
|
||||||
"toml_edit",
|
"toml_edit 0.25.13+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4944,6 +5359,12 @@ dependencies = [
|
|||||||
"xattr",
|
"xattr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "target-features"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5023,7 +5444,7 @@ dependencies = [
|
|||||||
"half",
|
"half",
|
||||||
"quick-error",
|
"quick-error",
|
||||||
"weezl",
|
"weezl",
|
||||||
"zune-jpeg",
|
"zune-jpeg 0.5.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5116,6 +5537,18 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.4+spec-1.1.0"
|
version = "1.1.4+spec-1.1.0"
|
||||||
@@ -5124,11 +5557,20 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"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]]
|
[[package]]
|
||||||
@@ -5140,6 +5582,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.13+spec-1.1.0"
|
version = "0.25.13+spec-1.1.0"
|
||||||
@@ -5147,10 +5603,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5159,9 +5615,15 @@ version = "1.1.3+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.2+spec-1.1.0"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -6187,6 +6650,15 @@ dependencies = [
|
|||||||
"xkbcommon-dl",
|
"xkbcommon-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -6396,7 +6868,7 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
"zbus_macros",
|
"zbus_macros",
|
||||||
"zbus_names",
|
"zbus_names",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
@@ -6424,7 +6896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6516,6 +6988,12 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -6531,13 +7009,22 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "zune-jpeg"
|
name = "zune-jpeg"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zune-core",
|
"zune-core 0.5.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6549,7 +7036,7 @@ dependencies = [
|
|||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
]
|
]
|
||||||
@@ -6577,5 +7064,5 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"core/dr-types",
|
"core/dr-types",
|
||||||
|
"core/dr-decode",
|
||||||
"core/dr-gpu",
|
"core/dr-gpu",
|
||||||
"core/dr-sync",
|
"core/dr-sync",
|
||||||
"ui/dr-ui",
|
"ui/dr-ui",
|
||||||
@@ -19,6 +20,7 @@ repository = "https://github.com/dtourolle/DarkRoom"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal
|
# Internal
|
||||||
dr-types = { path = "core/dr-types" }
|
dr-types = { path = "core/dr-types" }
|
||||||
|
dr-decode = { path = "core/dr-decode" }
|
||||||
dr-gpu = { path = "core/dr-gpu" }
|
dr-gpu = { path = "core/dr-gpu" }
|
||||||
dr-sync = { path = "core/dr-sync" }
|
dr-sync = { path = "core/dr-sync" }
|
||||||
dr-ui = { path = "ui/dr-ui" }
|
dr-ui = { path = "ui/dr-ui" }
|
||||||
@@ -45,6 +47,11 @@ async-trait = "0.1"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.23"
|
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"] }
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
//! DarkRoom desktop entry point.
|
//! DarkRoom desktop entry point.
|
||||||
|
//!
|
||||||
|
//! darkroom-desktop <file-or-directory>...
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
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();
|
.init();
|
||||||
|
|
||||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
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 |
|
| 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 |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 16 |
|
| Source files scanned | 20 |
|
||||||
| TRACES tags found | 16 |
|
| TRACES tags found | 24 |
|
||||||
| Requirements defined | 143 |
|
| Requirements defined | 143 |
|
||||||
| Requirements covered | 19 |
|
| Requirements covered | 29 |
|
||||||
| **Coverage** | **13.3%** (19/143) |
|
| **Coverage** | **20.3%** (29/143) |
|
||||||
|
|
||||||
### By type
|
### By type
|
||||||
|
|
||||||
| Type | Covered | Defined |
|
| Type | Covered | Defined |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FR | 13 | 90 |
|
| FR | 20 | 90 |
|
||||||
| NFR | 4 | 47 |
|
| NFR | 7 | 47 |
|
||||||
| R | 2 | 6 |
|
| R | 2 | 6 |
|
||||||
|
|
||||||
## Orphan tags
|
## 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-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-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-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-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-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-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-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-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-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-UI-1 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) |
|
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) |
|
| 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-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-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-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-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) |
|
| 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) |
|
| R4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) |
|
||||||
|
|
||||||
## Not yet tagged
|
## 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>
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
@@ -66,13 +76,10 @@ _None._
|
|||||||
- FR-CAT-14
|
- FR-CAT-14
|
||||||
- FR-CAT-3
|
- FR-CAT-3
|
||||||
- FR-CAT-4
|
- FR-CAT-4
|
||||||
- FR-CAT-5
|
|
||||||
- FR-CAT-6
|
- FR-CAT-6
|
||||||
- FR-CAT-7
|
- FR-CAT-7
|
||||||
- FR-CAT-8
|
- FR-CAT-8
|
||||||
- FR-CAT-9
|
- FR-CAT-9
|
||||||
- FR-CULL-1
|
|
||||||
- FR-CULL-2
|
|
||||||
- FR-CULL-3
|
- FR-CULL-3
|
||||||
- FR-CULL-4
|
- FR-CULL-4
|
||||||
- FR-CULL-5
|
- FR-CULL-5
|
||||||
@@ -107,7 +114,6 @@ _None._
|
|||||||
- FR-EXP-6
|
- FR-EXP-6
|
||||||
- FR-EXP-7
|
- FR-EXP-7
|
||||||
- FR-EXP-8
|
- FR-EXP-8
|
||||||
- FR-EXP-9
|
|
||||||
- FR-NC-1
|
- FR-NC-1
|
||||||
- FR-NC-10
|
- FR-NC-10
|
||||||
- FR-NC-11
|
- FR-NC-11
|
||||||
@@ -128,9 +134,6 @@ _None._
|
|||||||
- FR-PLAT-LIN-2
|
- FR-PLAT-LIN-2
|
||||||
- FR-PLAT-LIN-3
|
- FR-PLAT-LIN-3
|
||||||
- FR-RAW-2
|
- FR-RAW-2
|
||||||
- FR-RAW-3
|
|
||||||
- FR-RAW-4
|
|
||||||
- FR-RAW-5
|
|
||||||
- FR-UI-3
|
- FR-UI-3
|
||||||
- FR-UI-4
|
- FR-UI-4
|
||||||
- FR-UI-5
|
- FR-UI-5
|
||||||
@@ -151,7 +154,6 @@ _None._
|
|||||||
- NFR-P10
|
- NFR-P10
|
||||||
- NFR-P11
|
- NFR-P11
|
||||||
- NFR-P12
|
- NFR-P12
|
||||||
- NFR-P13
|
|
||||||
- NFR-P14
|
- NFR-P14
|
||||||
- NFR-P15
|
- NFR-P15
|
||||||
- NFR-P2
|
- NFR-P2
|
||||||
@@ -171,11 +173,9 @@ _None._
|
|||||||
- NFR-R4
|
- NFR-R4
|
||||||
- NFR-R5
|
- NFR-R5
|
||||||
- NFR-R6
|
- NFR-R6
|
||||||
- NFR-RES-1
|
|
||||||
- NFR-RES-2
|
- NFR-RES-2
|
||||||
- NFR-RES-3
|
- NFR-RES-3
|
||||||
- NFR-RES-4
|
- NFR-RES-4
|
||||||
- NFR-SEC-1
|
|
||||||
- NFR-SEC-2
|
- NFR-SEC-2
|
||||||
- NFR-SEC-3
|
- NFR-SEC-3
|
||||||
- NFR-SEC-4
|
- NFR-SEC-4
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
dr-types.workspace = true
|
dr-types.workspace = true
|
||||||
dr-gpu.workspace = true
|
dr-gpu.workspace = true
|
||||||
|
dr-decode.workspace = true
|
||||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
|
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
|
||||||
wgpu.workspace = true
|
wgpu.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
|||||||
+277
-183
@@ -1,91 +1,206 @@
|
|||||||
//! Slint interface for DarkRoom.
|
//! Slint interface for DarkRoom.
|
||||||
//!
|
//!
|
||||||
//! v0.1 exists to validate assumption A1: compute output reaching the screen
|
//! v0.1 is a viewer: open a folder of RAW files, extract embedded previews,
|
||||||
//! without a CPU round-trip (ARCH §6.1).
|
//! display them.
|
||||||
//!
|
//!
|
||||||
//! **Current state — read this before assuming A1 is proven.** Slint's public
|
//! **Read before assuming A1 is proven.** Slint's public API for adopting an
|
||||||
//! API for adopting an externally-created wgpu texture is not yet wired up
|
//! externally created wgpu texture is not wired up here; this build uploads
|
||||||
//! here; this build uploads through `SharedPixelBuffer`, which *is* a CPU
|
//! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the
|
||||||
//! round-trip. It is correct and cross-platform, but it is explicitly the
|
//! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1
|
||||||
//! thing the architecture forbids in production.
|
//! is unvalidated.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dr_gpu::{GpuContext, RenderTarget};
|
use dr_decode::{Metadata, PreviewSize};
|
||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
/// Frame timing, averaged over a short window so the readout is stable enough
|
/// TRACES: FR-DSP-1 | NFR-RES-1
|
||||||
/// to read.
|
/// Longest edge the viewer renders at.
|
||||||
struct FrameClock {
|
///
|
||||||
last: Instant,
|
/// FR-DSP-1: work at the resolution the viewport needs, not the source
|
||||||
accum: f32,
|
/// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB,
|
||||||
frames: u32,
|
/// which is what keeps a folder browsable within NFR-RES-1's budget.
|
||||||
fps: i32,
|
const MAX_DISPLAY_DIM: u32 = 2048;
|
||||||
start: Instant,
|
|
||||||
|
/// 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 {
|
/// Load and decode one image for display.
|
||||||
fn new() -> Self {
|
///
|
||||||
let now = Instant::now();
|
/// Reads the whole file because rawler needs the full container to locate a
|
||||||
Self {
|
/// preview. The remote path (FR-NC-3) fetches only a byte range, which is why
|
||||||
last: now,
|
/// the decode API takes bytes rather than a reader.
|
||||||
accum: 0.0,
|
fn load(path: &Path) -> Result<Loaded, String> {
|
||||||
frames: 0,
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
fps: 0,
|
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||||
start: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advance one frame; returns elapsed seconds since start.
|
let mut preview =
|
||||||
fn tick(&mut self) -> f32 {
|
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||||
let now = Instant::now();
|
|
||||||
let dt = now.duration_since(self.last).as_secs_f32();
|
|
||||||
self.last = now;
|
|
||||||
|
|
||||||
self.accum += dt;
|
// Bound memory before handing pixels to the UI.
|
||||||
self.frames += 1;
|
preview.downscale_to(MAX_DISPLAY_DIM);
|
||||||
if self.accum >= 0.5 {
|
|
||||||
self.fps = (self.frames as f32 / self.accum).round() as i32;
|
|
||||||
self.accum = 0.0;
|
|
||||||
self.frames = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
/// Collect displayable images from file or directory arguments.
|
||||||
pub fn run() -> Result<()> {
|
fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
let ctx = pollster::block_on(GpuContext::new_headless())?;
|
let mut out = Vec::new();
|
||||||
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
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()?;
|
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)?));
|
// Report the GPU even though v0.1 displays through the CPU path: the
|
||||||
let clock = Rc::new(RefCell::new(FrameClock::new()));
|
// adapter is what spike S1 exercises, and showing it makes vendor
|
||||||
// Desired canvas size, applied once per frame rather than per resize
|
// differences obvious during that work.
|
||||||
// event. A window drag emits dozens of events a second, and each one
|
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||||||
// would otherwise reallocate the texture.
|
Ok(ctx) => {
|
||||||
let pending_size = Rc::new(std::cell::Cell::new((1280u32, 720u32)));
|
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
|
window.set_total(entries.len() as i32);
|
||||||
// this per-dimension, so both callbacks land on the same handler.
|
let index = Rc::new(RefCell::new(0usize));
|
||||||
{
|
|
||||||
let pending = pending_size.clone();
|
let show = {
|
||||||
window.on_canvas_resized(move |w, h| {
|
let entries = entries.clone();
|
||||||
if w > 0 && h > 0 {
|
let index = index.clone();
|
||||||
pending.set((w as u32, h as u32));
|
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);
|
apply_layout_class(&window, width);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Seed from the initial window size; `window-resized` maintains it after.
|
|
||||||
{
|
{
|
||||||
let size = window.window().size();
|
let size = window.window().size();
|
||||||
let scale = window.window().scale_factor().max(0.01);
|
let scale = window.window().scale_factor().max(0.01);
|
||||||
apply_layout_class(&window, size.width as f32 / scale);
|
apply_layout_class(&window, size.width as f32 / scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drive rendering from a timer rather than a redraw hook: v0.1 animates
|
if !entries.is_empty() {
|
||||||
// continuously to make a stalled frame obvious. Real rendering is
|
show(&window);
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.run()?;
|
window.run()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upper bound on render resolution.
|
fn describe_camera(m: &Metadata) -> String {
|
||||||
///
|
match (&m.make, &m.model) {
|
||||||
/// FR-DSP-1: the display pipeline works at the resolution the viewport needs,
|
(Some(make), Some(model)) => {
|
||||||
/// not the source resolution. The same reasoning applies to the window — a
|
// Model often repeats the make; "Canon Canon EOS 6D" reads badly.
|
||||||
/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at
|
if model.starts_with(make.as_str()) {
|
||||||
/// while dragging. Real zoom-to-1:1 will render the visible crop at full
|
model.trim().to_string()
|
||||||
/// resolution instead of scaling the whole canvas up.
|
} else {
|
||||||
/// TRACES: FR-DSP-1
|
format!("{} {}", make.trim(), model.trim())
|
||||||
const MAX_RENDER_DIM: u32 = 2048;
|
}
|
||||||
|
}
|
||||||
fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
|
(_, Some(model)) => model.trim().to_string(),
|
||||||
let w = w.max(1);
|
(Some(make), _) => make.trim().to_string(),
|
||||||
let h = h.max(1);
|
_ => String::new(),
|
||||||
let longest = w.max(h);
|
|
||||||
if longest <= MAX_RENDER_DIM {
|
|
||||||
return (w, h);
|
|
||||||
}
|
}
|
||||||
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).
|
fn describe_exposure(m: &Metadata) -> String {
|
||||||
///
|
let mut parts = Vec::new();
|
||||||
/// A threshold in logical pixels, not a device check — a narrow desktop window
|
if let Some(s) = m.shutter {
|
||||||
/// gets the compact layout exactly as a tablet in portrait would.
|
// Photographers read fractions, not decimals.
|
||||||
/// TRACES: FR-UI-1 | FR-UI-2
|
parts.push(if s >= 1.0 {
|
||||||
const EXPANDED_MIN_WIDTH: f32 = 820.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) {
|
fn apply_layout_class(window: &AppWindow, width: f32) {
|
||||||
let expanded = width >= EXPANDED_MIN_WIDTH;
|
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());
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn meta() -> Metadata {
|
||||||
fn render_size_is_capped_preserving_aspect() {
|
Metadata {
|
||||||
// Under the cap, untouched.
|
make: Some("Canon".into()),
|
||||||
assert_eq!(clamp_render_size(1600, 900), (1600, 900));
|
model: Some("Canon EOS 6D".into()),
|
||||||
|
shutter: Some(1.0 / 250.0),
|
||||||
// Over the cap, scaled down with aspect preserved.
|
aperture: Some(2.8),
|
||||||
let (w, h) = clamp_render_size(3840, 2160);
|
iso: Some(400),
|
||||||
assert_eq!(w, MAX_RENDER_DIM);
|
focal_length: Some(50.0),
|
||||||
assert!((h as f32 - 1152.0).abs() < 2.0, "got {h}");
|
..Default::default()
|
||||||
|
}
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn frame_clock_reports_after_window() {
|
fn camera_does_not_repeat_the_make() {
|
||||||
let mut c = FrameClock::new();
|
// rawler reports make "Canon" and model "Canon EOS 6D"; naive
|
||||||
// Before half a second elapses there is no average to report.
|
// concatenation gives "Canon Canon EOS 6D".
|
||||||
assert_eq!(c.fps, 0);
|
assert_eq!(describe_camera(&meta()), "Canon EOS 6D");
|
||||||
let t = c.tick();
|
}
|
||||||
assert!(t >= 0.0);
|
|
||||||
|
#[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> backend;
|
||||||
in property <string> layout-class;
|
in property <string> layout-class;
|
||||||
in property <int> fps;
|
in property <int> fps;
|
||||||
|
in property <string> filename;
|
||||||
|
in property <string> position;
|
||||||
|
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
@@ -28,13 +30,21 @@ component StatusBar inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.adapter;
|
text: root.filename;
|
||||||
color: Theme.ink-dim;
|
color: Theme.ink;
|
||||||
font-size: Theme.text-sm;
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 600;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.position;
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle { horizontal-stretch: 1; }
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -62,6 +72,10 @@ component StatusBar inherits Rectangle {
|
|||||||
// A placeholder panel standing in for the adjustment controls that FR-DEV-3a
|
// A placeholder panel standing in for the adjustment controls that FR-DEV-3a
|
||||||
// will generate from operation descriptors.
|
// will generate from operation descriptors.
|
||||||
component SidePanel inherits Rectangle {
|
component SidePanel inherits Rectangle {
|
||||||
|
in property <string> camera;
|
||||||
|
in property <string> exposure;
|
||||||
|
in property <string> dimensions;
|
||||||
|
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
@@ -70,7 +84,7 @@ component SidePanel inherits Rectangle {
|
|||||||
alignment: start;
|
alignment: start;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "DEVELOP";
|
text: "IMAGE";
|
||||||
color: Theme.accent;
|
color: Theme.accent;
|
||||||
font-size: Theme.text-sm;
|
font-size: Theme.text-sm;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -78,10 +92,30 @@ component SidePanel inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
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;
|
color: Theme.ink-faint;
|
||||||
font-size: Theme.text-sm;
|
font-size: Theme.text-sm;
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +139,18 @@ export component AppWindow inherits Window {
|
|||||||
in property <string> backend: "—";
|
in property <string> backend: "—";
|
||||||
in property <int> fps: 0;
|
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
|
// FR-UI-1: layout class follows window width, not device type. A narrow
|
||||||
// desktop window gets the compact layout, exactly as a tablet would.
|
// desktop window gets the compact layout, exactly as a tablet would.
|
||||||
//
|
//
|
||||||
@@ -126,6 +172,8 @@ export component AppWindow inherits Window {
|
|||||||
backend: root.backend;
|
backend: root.backend;
|
||||||
layout-class: root.layout-class;
|
layout-class: root.layout-class;
|
||||||
fps: root.fps;
|
fps: root.fps;
|
||||||
|
filename: root.filename;
|
||||||
|
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -141,6 +189,43 @@ export component AppWindow inherits Window {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
source: root.canvas;
|
source: root.canvas;
|
||||||
image-fit: contain;
|
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
|
// Report size changes so the render target can be resized to
|
||||||
@@ -160,6 +245,9 @@ export component AppWindow inherits Window {
|
|||||||
SidePanel {
|
SidePanel {
|
||||||
width: root.expanded ? 260px : 0px;
|
width: root.expanded ? 260px : 0px;
|
||||||
visible: root.expanded;
|
visible: root.expanded;
|
||||||
|
camera: root.camera;
|
||||||
|
exposure: root.exposure;
|
||||||
|
dimensions: root.dimensions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user