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:
2026-08-09 08:28:26 +02:00
parent 0f202fd3f9
commit 9717e59909
13 changed files with 1711 additions and 230 deletions
Generated
+505 -18
View File
@@ -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",
] ]
+7
View File
@@ -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]
+12 -2
View File
@@ -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)
} }
+16
View File
@@ -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
+146
View File
@@ -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])
}
}
+52
View File
@@ -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());
}
}
+266
View File
@@ -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());
}
}
+313
View File
@@ -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, &params),
PreviewSize::Screen => decoder.preview_image(&source, &params),
PreviewSize::Full => decoder.full_image(&source, &params),
};
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());
}
}
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
} }
} }