M2: thumbnail engine — turbojpeg DCT-shrink decode, worker pool, disk cache

- images.rs: libjpeg-turbo 1/2..1/8 shrink decode (48MP @1/8 ~2.3MB peak),
  all 8 EXIF orientations, imageops::resize final fit, PNG via image crate,
  q85 re-encode for the disk cache
- thumb.rs: dispatcher + N=cores/2 workers (2..8), cache
  thumbs/{sha1(path|mtime|size|target)}.jpg (target in key: list 64px and
  grid 500px coexist), atomic rename, generation-based cancellation,
  per-worker sqlite set_thumb, 256-job cadence logs
- lib.rs: AppState OnceLock, 10Hz drain timer (leaked !Send Timer),
  scroll prefetch (50-row margin), do_prefetch gating for scan batches
- main.slint/db.rs: rows carry images; list_entries + set_thumb
- build.sh: NDK CMake toolchain env for turbojpeg-sys (ANDROID_JAR vs
  ANDROID_PLATFORM conflict), nasm on PATH, ADB_SERIAL, debug-aarch64
- HANDOFF: M2 done + acceptance data; HEIC deferred (0 HEIC on device);
  transient first-launch black screen parked as §10.7 (unreproducible)

Verified: 12 unit tests + 5k engine test; Pixel 9 Pro 8577 photos —
cold scan ~5s streaming, warm relaunch instant, thumbnails in every row,
~100% cache hits on relaunch; release aarch64 APK 9.8 MB.
This commit is contained in:
Greg Pomerantz 2026-10-27 16:32:12 -04:00
parent c72b00f7ec
commit 572409b702
10 changed files with 1388 additions and 117 deletions

134
Cargo.lock generated
View File

@ -258,6 +258,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.7.0" version = "3.7.0"
@ -558,6 +564,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@ -771,6 +786,15 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.13.1" version = "0.13.1"
@ -826,6 +850,12 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.10.0"
@ -881,6 +911,15 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.3" version = "1.5.3"
@ -945,6 +984,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ctor" name = "ctor"
version = "0.10.1" version = "0.10.1"
@ -989,6 +1037,17 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -1495,11 +1554,15 @@ name = "gallery"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"image",
"kamadak-exif",
"log", "log",
"rusqlite", "rusqlite",
"sha1",
"slint", "slint",
"slint-build", "slint-build",
"tempfile", "tempfile",
"turbojpeg",
] ]
[[package]] [[package]]
@ -1524,6 +1587,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "gcd"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a"
[[package]] [[package]]
name = "gethostname" name = "gethostname"
version = "1.1.0" version = "1.1.0"
@ -1780,6 +1849,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "i-slint-backend-android-activity" name = "i-slint-backend-android-activity"
version = "1.18.1" version = "1.18.1"
@ -2506,6 +2584,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kamadak-exif"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
dependencies = [
"mutate_once",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@ -2807,6 +2894,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]] [[package]]
name = "naga" name = "naga"
version = "30.0.1" version = "30.0.1"
@ -4067,6 +4160,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -4747,6 +4851,30 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "turbojpeg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81313fcd64aaa30ddfd59eaa122968136b2dd9cd41d9619b126cf71e67a01702"
dependencies = [
"gcd",
"libc",
"thiserror 1.0.69",
"turbojpeg-sys",
]
[[package]]
name = "turbojpeg-sys"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b5a974bc59382d35927e3a07eef2ba9a24fe7227cefe52ec44b508e1b90f86"
dependencies = [
"anyhow",
"cmake",
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "typed-index-collections" name = "typed-index-collections"
version = "3.5.0" version = "3.5.0"
@ -4757,6 +4885,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "udev" name = "udev"
version = "0.9.3" version = "0.9.3"

View File

@ -11,6 +11,10 @@ slint = { version = "1.18.1", features = ["backend-android-activity-06"] }
log = "0.4" log = "0.4"
android_logger = { version = "0.14", target-family = "android" } android_logger = { version = "0.14", target-family = "android" }
rusqlite = { version = "0.41.0", features = ["bundled"] } rusqlite = { version = "0.41.0", features = ["bundled"] }
sha1 = "0.11.0"
turbojpeg = { version = "1.5.1", features = ["cmake"] }
image = { version = "0.25.10", default-features = false, features = ["png"] }
kamadak-exif = "0.6.1"
[build-dependencies] [build-dependencies]
slint-build = "1.18.1" slint-build = "1.18.1"

View File

@ -6,9 +6,11 @@ point for development: it captures every decision, gotcha, and measurement
from the framework evaluation that preceded it, so no learning has to be from the framework evaluation that preceded it, so no learning has to be
rediscovered. rediscovered.
**Status: M0 done** — this scaffold builds and runs on the emulator **Status: M2 done (2026-10-27)** — M0 scaffold, M1 metadata scan + sqlite +
(`./scripts/build.sh debug`), including the permission-aware Android init and instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink decode, worker
the lifecycle-event hook. pool, disk cache, cancellation) all verified: unit tests, 5k synthetic engine
test, and visually on a real Pixel 9 Pro (8,577 photos, thumbnails in every
row). Next: M3 grid UI.
--- ---
@ -117,18 +119,17 @@ thumbnail engine, and it is framework-agnostic:
└────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────┘
``` ```
Planned module layout: Module layout:
``` ```
src/ src/
lib.rs # android_main, ANDROID_APP stash, platform init [exists] lib.rs # android_main + platform init + scan/UI glue (M1/M2)
main.rs # desktop entry [exists] main.rs # desktop entry [exists]
main.slint # screens (split into ui/ components at M3) [exists] main.slint # screens (split into ui/ components at M3) [exists]
db.rs # sqlite schema + scan (M1) db.rs # sqlite schema + incremental scan apply [done M1]
scan.rs # directory walk, image detection, metadata read (M1) scan.rs # directory walk, image detection, walk_stream [done M1]
thumb.rs # pool, decode@size, disk cache, cancellation (M2) thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
images.rs # decode dispatch (zune-jpeg / imagepipe / png) (M2) images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
ui.rs # Rust↔Slint glue: Model provider, thumb→Image (M3)
viewer.rs # full-size load + transform state (M4) viewer.rs # full-size load + transform state (M4)
perm.rs # storage permission check/request (M5) perm.rs # storage permission check/request (M5)
``` ```
@ -138,11 +139,12 @@ Crate choices (all verified current on crates.io, 2026-10):
| crate | version | why | | crate | version | why |
|---|---|---| |---|---|---|
| `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands | | `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
| `rusqlite` | latest, `bundled` feature | SQLite without system deps; cross-compiles to Android | | `rusqlite` | 0.41, `bundled` feature | SQLite without system deps; cross-compiles to Android |
| `zune-jpeg` | 0.5.x (upd 2026-10) | pure-Rust JPEG, **turbo mode** = decode+downscale one pass. Preferred over `turbojpeg` for Android: no C dep to cross-build | | `turbojpeg` | 1.5.1, `cmake` feature | libjpeg-turbo DCT-level shrink decode (1/2,1/4,1/8) — 48 MP at 1/8 peaks ~2.3 MB vs 137 MB full decode. Vendors + cross-builds libjpeg-turbo via CMake/NDK (verified). Chosen over `libvips` (same DCT shrink mechanism, +6–10 MB of .so files, LGPL, 4.3k downloads) and over `zune-jpeg` (no DCT shrink = full-size decode for 12 MP photos). |
| `image` | 0.25.x | PNG + header/metadata (`ImageReader::into_dimensions`, EXIF orientation) | | `image` | 0.25, `default-features=false, png` | PNG thumbs + `imageops::resize` final fit |
| `imagepipe` | 0.5.x (upd 2026-08) | HEIC/HEIF via Apple ImagePipe — **open question: Android NDK support** (see §10) | | `kamadak-exif` | 0.6 | EXIF orientation tag (all 8 values applied as pixel maps); `experimental::Writer` used only in tests |
| `slint::Image::from_rgba8_premultiplied` | — | how decoded thumbs enter the UI | | `sha1` | 0.11 | cache keys |
| `imagepipe` | — (deferred) | HEIC: the real device has **0 HEIC** files (9109 JPEG, 27 PNG), so HEIC support is deferred; `image` can gain `heic` or libvips can replace the JPEG path later — the engine API is decoder-agnostic |
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side. Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side.
Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in
@ -302,10 +304,42 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
survives `adb install -r`. survives `adb install -r`.
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644` - Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
or the app can't read them. or the app can't read them.
- **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache; - **M2 — thumbnail engine (DONE, 2026-10-27)**: `images.rs` (turbojpeg
cancellation; pool; synthetic 5k test set; timing logs. DCT-level shrink decode via 1/2/1/4/1/8 `ScalingFactor`, all 8 EXIF
*Accept: first grid fill < 2 s; re-launch fills instantly from disk cache; orientations as uniform `(x,y)→(dx,dy)` pixel maps, `image::imageops::resize`
fast scroll causes no backlog (watch pending-queue length in logs).* final fit, re-encode to disk cache at q85; PNG via `image`) + `thumb.rs`
(dispatcher thread round-robining to N=cores/2 workers clamped 2..8,
unbounded job queue, disk cache `thumbs/{sha1(path|mtime|size|target)}.jpg`
— target in the key so list-64px and grid-500px coexist; atomic
`.tmp`→rename; generation counter for cancellation; per-worker sqlite
connection writes `set_thumb` w/h/orientation/thumb_path) + `lib.rs`
(process-wide `AppState` in `OnceLock`, 10 Hz drain timer — `Timer` is
`!Send`, leaked with `std::mem::forget`, scroll-prefetch with 50-row
margin, `do_prefetch` flag so streaming scan batches don't re-prefetch) +
`main.slint` (rows carry `image` now).
*Accept, verified:* 12 unit tests + 5k synthetic engine test (cold fill,
100% cache warm refill, generation cancel). Release aarch64 APK 9.8 MB.
**Real device (Pixel 9 Pro, 8577 photos):** cold scan ~5 s with list
streaming; warm relaunch paints the cached list instantly; thumbnails
render in every visible row (screenshot-verified); relaunch cache hits
~100% (255–258 of first 256 jobs); 2 WebP files misnamed `.jpg` fail
gracefully (logged, row stays empty). Worker log cadence: every 256 jobs.
*Known transient (unreproducible): the very first aarch64-release install
rendered a pure-black screen (no drain-timer "painted" logs) until the
next launch; it did **not** reproduce on the emulator (screen-off launch +
wake) nor across repeated device launches. Suspected screen-lock / display
state race on first GL frame. If it recurs: force a window refresh on
Resume (see §10.7).*
*Build gotchas hit (also in build.sh comments):*
- `ANDROID_PLATFORM=android-26` (NDK CMake toolchain) **conflicts** with
the `android-build` crate (Slint's Java compile) unless `ANDROID_JAR`
is set explicitly to `platforms/android-35/android.jar`.
- x86_64 host builds of turbojpeg-sys need `nasm` (`~/.local/bin/nasm`;
aarch64 does not).
- `ADB_SERIAL=…` env var picks the device in build.sh (phone + emulator
are often both attached).
- build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`,
`aarch64`; second arg `no-install` to skip install.
- **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer, - **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer,
pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery → pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery →
cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped- cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped-
@ -324,12 +358,10 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
## 10. Open questions & risks ## 10. Open questions & risks
1. **HEIC on Android**: `imagepipe` is Apple's ImagePipe — its Rust crate 1. **HEIC (RESOLVED for now)**: the real device has 0 HEIC files (9109
may only ship Apple prebuilts. **First task of M2: verify an NDK build** JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
(`cargo apk2 build` with imagepipe). Fallbacks: `libheif-rs`/libheif `images.rs` dispatch (libheif-rs / `image` heic feature / libvips);
(C, cross-buildable), or skip HEIC initially and document JPEG/PNG only the engine API is decoder-agnostic.
(the emulator test set is JPEG anyway; real device photos from this
ecosystem may already be JPEG — check passgo's existing files).
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android 2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
backend at eval time): could fire on trackpad/touch scroll. Test scrolling backend at eval time): could fire on trackpad/touch scroll. Test scrolling
on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19 on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19
@ -344,6 +376,13 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade. @ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade.
6. **rusqlite `bundled`** compiles SQLite from source in every target 6. **rusqlite `bundled`** compiles SQLite from source in every target
(adds ~30–60 s to clean builds) — acceptable; revisit if it hurts. (adds ~30–60 s to clean builds) — acceptable; revisit if it hurts.
7. **Transient black screen (M2 first launch, unreproducible)**: one
aarch64-release launch rendered pure black with a dead drain timer until
relaunch; not reproduced on emulator or device since. Candidate fix if it
recurs: in the `init_with_event_listener` Resume handler, call
`window.request_redraw()` / check `window.size()` (a 0×0 or stale surface
after a screen-off start would match the symptoms). Don't add it
speculatively.
## 11. Pointers ## 11. Pointers

View File

@ -13,10 +13,33 @@ cd "$(dirname "$0")/.."
export ANDROID_HOME=$HOME/android-sdk export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973 export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH" # ~/.local/bin: nasm (host build of libjpeg-turbo SIMD; aarch64 needs no nasm).
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
# turbojpeg (M2): build the vendored libjpeg-turbo with CMake, statically linked
# into the app (no extra .so in the APK). The NDK CMake toolchain + ANDROID_*
# vars are picked up by the `cmake` crate; verified 2026-10 (HANDOFF §10).
if [ -n "$ANDROID_NDK_ROOT" ]; then
export CMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake
export ANDROID_PLATFORM=android-26
export ANDROID_STL=c++_static
export TURBOJPEG_STATIC=1
fi
# SDK platform used to compile the Slint Java helper (target API, matches
# target_sdk_version in Cargo.toml). Must be an installed platform dir.
SDK_PLATFORM=android-35
# Slint's android-activity build reads ANDROID_JAR first; setting it explicitly
# lets us ALSO export ANDROID_PLATFORM=android-26 below (for the NDK CMake
# toolchain's native API level) without the two tools fighting over that
# variable (`android-build` would otherwise look up platforms/android-26).
export ANDROID_JAR=$ANDROID_HOME/platforms/$SDK_PLATFORM/android.jar
# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite). # C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite).
# The `cc` crate picks these up per target triple (min sdk 26). # The `cc` crate picks these up per target triple (min sdk 26).
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
# CMake target ABI (x86_64 default; overridden below for aarch64).
export ANDROID_ABI=x86_64
export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang
export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++ export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
@ -32,17 +55,23 @@ arg2=${2:-install}
profile=--release profile=--release
case $arg1 in case $arg1 in
debug) target=x86_64-linux-android; profile="" ;; debug) target=x86_64-linux-android; profile="" ;;
x86_64) target=x86_64-linux-android ;; debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;;
aarch64) target=aarch64-linux-android ;; x86_64) target=x86_64-linux-android ;;
aarch64) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a ;;
*) echo "unknown arch: $arg1" >&2; exit 1 ;; *) echo "unknown arch: $arg1" >&2; exit 1 ;;
esac esac
profile_dir=release; [ -z "$profile" ] && profile_dir=debug
# Optional: ADB_SERIAL picks the device when several are attached (see AGENTS.md).
adb=(); [ -n "$ADB_SERIAL" ] && adb=(adb -s "$ADB_SERIAL")
cargo apk2 build $profile --target "$target" --lib cargo apk2 build $profile --target "$target" --lib
apk=$(ls target/*/apk/gallery.apk | head -1) apk=target/$profile_dir/apk/gallery.apk
echo "APK: $apk ($(du -h "$apk" | cut -f1))" echo "APK: $apk ($(du -h "$apk" | cut -f1))"
if [ "$arg2" = install ] && command -v adb >/dev/null; then if [ "$arg2" = install ] && command -v adb >/dev/null; then
adb install -r "$apk" "${adb[@]}" install -r "$apk"
adb shell am start -n wow.gmp.gallery/android.app.NativeActivity || true "${adb[@]}" shell am start -n wow.gmp.gallery/android.app.NativeActivity || true
fi fi

View File

@ -39,6 +39,9 @@ fn android_data_dir() -> PathBuf {
/// - Android: per-app external dir (see `android_data_dir`), internal fallback. /// - Android: per-app external dir (see `android_data_dir`), internal fallback.
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery). /// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
pub fn db_path() -> PathBuf { pub fn db_path() -> PathBuf {
if let Ok(p) = std::env::var("GALLERY_DB_PATH") {
return PathBuf::from(p); // test hook
}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
let dir = android_data_dir(); let dir = android_data_dir();
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@ -137,10 +140,12 @@ pub fn apply(
Ok((inserted, updated, deleted)) Ok((inserted, updated, deleted))
} }
/// All known paths, newest first. /// All known entries, newest first.
pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error> { pub fn list_entries(conn: &mut Connection) -> Result<Vec<Entry>, rusqlite::Error> {
let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?; let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
let rows = st.query_map([], |r| r.get::<_, String>(0))?; let rows = st.query_map([], |r| {
Ok(Entry { path: r.get(0)?, mtime: r.get(1)?, size: r.get(2)? })
})?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
out.push(row?); out.push(row?);
@ -148,6 +153,23 @@ pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error>
Ok(out) Ok(out)
} }
/// Update the derived thumbnail columns once the pool has finished a job.
pub fn set_thumb(
conn: &mut Connection,
path: &str,
width: i64,
height: i64,
orientation: i64,
thumb_path: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE items SET width = ?2, height = ?3, orientation = ?4, thumb_path = ?5 \
WHERE path = ?1",
params![path, width, height, orientation, thumb_path],
)?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -177,10 +199,31 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!((i, u, d), (1, 1, 1)); assert_eq!((i, u, d), (1, 1, 1));
let paths = list_paths(&mut conn).unwrap(); let list = list_entries(&mut conn).unwrap();
let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]); assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
} }
#[test]
fn set_thumb_updates_row_and_invalidation() {
let dir = tempfile::tempdir().unwrap();
let mut conn = open(&dir.path().join("t.db")).unwrap();
apply(&mut conn, &[entry("/a/1.jpg", 1, 10)]).unwrap();
set_thumb(&mut conn, "/a/1.jpg", 8000, 6016, 6, "/cache/abc.jpg").unwrap();
let thumb: String = conn
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
.unwrap();
assert_eq!(thumb, "/cache/abc.jpg");
// mtime change → apply() invalidates the derived columns.
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 2, 12)]).unwrap();
assert_eq!((i, u, d), (0, 1, 0));
let thumb: Option<String> = conn
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
.unwrap();
assert_eq!(thumb, None);
}
#[test] #[test]
fn scan_then_apply_roundtrip() { fn scan_then_apply_roundtrip() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -195,8 +238,8 @@ mod tests {
let (i, u, d) = apply(&mut conn, &entries).unwrap(); let (i, u, d) = apply(&mut conn, &entries).unwrap();
assert_eq!((i, u, d), (2, 0, 0)); assert_eq!((i, u, d), (2, 0, 0));
let paths = list_paths(&mut conn).unwrap(); let list = list_entries(&mut conn).unwrap();
assert_eq!(paths.len(), 2); assert_eq!(list.len(), 2);
assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap()))); assert!(list.iter().all(|e| e.path.contains(dir.path().to_str().unwrap())));
} }
} }

331
src/images.rs Normal file
View File

@ -0,0 +1,331 @@
//! Decode dispatch + thumbnail creation (M2).
//!
//! JPEG: `turbojpeg` (libjpeg-turbo) DCT-level shrink decode — the same
//! shrink-on-load libvips wraps internally (HANDOFF §10: why turbojpeg, not
//! libvips, for Android). Pipeline:
//! 1. pick a scale (1/2, 1/4, 1/8) so the decode lands at >= target size;
//! 2. decode RGB at that scale (peak memory = scaled size, never full-size:
//! 48 MP -> 1000x752x3 = 2.3 MB at 1/8, vs 137 MB full);
//! 3. apply EXIF orientation once, to the small buffer (kamadak-exif);
//! 4. final fit-resize into the target box (`image::imageops`);
//! 5. re-encode JPEG for the disk cache (turbojpeg `Compressor`).
//!
//! PNG: `image` crate (png feature), full decode + resize (27 files on the
//! test device — not the hot path).
//!
//! Output is RGB8 (content is opaque) and goes into
//! `slint::Image::from_rgb8(SharedPixelBuffer)` on the UI thread.
use std::io::Cursor;
use std::path::Path;
use exif::{In, Reader, Tag};
use image::imageops;
use image::imageops::FilterType;
use image::{ImageBuffer, Rgb};
use turbojpeg::{Decompressor, Image, PixelFormat, ScalingFactor};
/// A finished thumbnail: RGB8, EXIF orientation applied, fitted into a
/// `target x target` box (aspect preserved, never upscaled).
#[derive(Debug, Clone)]
pub struct Thumb {
pub rgb: Vec<u8>,
pub width: u32,
pub height: u32,
/// Source dimensions after orientation is applied (full resolution).
pub src_width: u32,
pub src_height: u32,
/// EXIF orientation 1..8 (1 = none).
pub orientation: u8,
}
/// Decode a file at full (1/1) size to RGBA8. Used only for small JPEGs in the
/// disk cache on a cache hit.
pub fn decode_jpeg(path: &Path) -> Result<Thumb, String> {
let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let (rgb, w, h) = decode_jpeg_scaled(&data, ScalingFactor::ONE)?;
Ok(Thumb {
rgb,
width: w,
height: h,
src_width: w,
src_height: h,
orientation: 1,
})
}
/// Decode JPEG bytes at a fixed scaling factor to RGB8.
pub fn decode_jpeg_scaled(data: &[u8], scale: ScalingFactor) -> Result<(Vec<u8>, u32, u32), String> {
let mut d = Decompressor::new().map_err(|e| e.to_string())?;
let header = d
.read_header(data)
.map_err(|e| format!("jpeg header: {e}"))?
.scaled(scale);
let (w, h) = (header.width as u32, header.height as u32);
if w == 0 || h == 0 {
return Err("zero-size jpeg".into());
}
let mut img = Image {
pixels: vec![0u8; 3 * w as usize * h as usize],
width: w as usize,
pitch: 3 * w as usize,
height: h as usize,
format: PixelFormat::RGB,
};
d.set_scaling_factor(scale).map_err(|e| e.to_string())?;
d.decompress(data, img.as_deref_mut())
.map_err(|e| format!("jpeg decode: {e}"))?;
Ok((img.pixels, w, h))
}
/// Re-encode RGB8 as JPEG (disk-cache entries).
pub fn encode_jpeg(rgb: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, String> {
let img = Image {
pixels: rgb.to_vec(),
width: width as usize,
pitch: 3 * width as usize,
height: height as usize,
format: PixelFormat::RGB,
};
let mut c = turbojpeg::Compressor::new().map_err(|e| e.to_string())?;
c.set_quality(quality as i32).map_err(|e| e.to_string())?;
c.compress_to_vec(img.as_deref()).map_err(|e| e.to_string())
}
/// Full pipeline: decode + orient + fit to `target` box.
pub fn make_thumb(path: &Path, target: u32) -> Result<Thumb, String> {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) {
Some(ref ext) if ext == "jpg" || ext == "jpeg" => thumb_jpeg(path, target),
Some(ref ext) if ext == "png" => thumb_png(path, target),
_ => Err(format!("unsupported type: {}", path.display())),
}
}
fn thumb_jpeg(path: &Path, target: u32) -> Result<Thumb, String> {
let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut d = Decompressor::new().map_err(|e| e.to_string())?;
let header = d.read_header(&data).map_err(|e| format!("jpeg header: {e}"))?;
let (fw, fh) = (header.width as u32, header.height as u32);
let orientation = exif_orientation(&data);
// Post-orientation full dimensions (90/270 swap w/h).
let (src_w, src_h) = if swaps_wh(orientation) { (fh, fw) } else { (fw, fh) };
let scale = pick_scale(src_w.max(src_h), target);
let (rgb, w, h) = decode_jpeg_scaled(&data, scale)?;
let (rgb, w, h) = apply_orientation(rgb, w, h, orientation);
Ok(fit_to_target(rgb, w, h, target, src_w, src_h, orientation))
}
fn thumb_png(path: &Path, target: u32) -> Result<Thumb, String> {
let img = image::ImageReader::open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?
.with_guessed_format()
.map_err(|e| e.to_string())?
.decode()
.map_err(|e| format!("png decode: {e}"))?;
let rgb = img.to_rgb8();
let (w, h) = (rgb.width(), rgb.height());
Ok(fit_to_target(rgb.into_raw(), w, h, target, w, h, 1))
}
/// Largest shrink (1/8, 1/4, 1/2) whose result is still >= `target` on the
/// longest side; never upscales.
fn pick_scale(max_dim: u32, target: u32) -> ScalingFactor {
for denom in [8u32, 4, 2] {
if max_dim / denom >= target {
return ScalingFactor::new(1, denom as usize);
}
}
ScalingFactor::ONE
}
fn swaps_wh(o: u8) -> bool {
matches!(o, 5 | 6 | 7 | 8)
}
/// EXIF orientation (1..8) from the file bytes; 1 when absent/invalid.
pub fn exif_orientation(data: &[u8]) -> u8 {
let mut reader = Cursor::new(data);
let Ok(exif) = Reader::new().read_from_container(&mut reader) else {
return 1;
};
exif.get_field(Tag::Orientation, In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
.filter(|v| (1..=8).contains(v))
.unwrap_or(1) as u8
}
/// Apply one of the 8 EXIF orientations to an RGB8 buffer (in place semantics:
/// returns a new buffer).
pub fn apply_orientation(rgb: Vec<u8>, w: u32, h: u32, orientation: u8) -> (Vec<u8>, u32, u32) {
// Output dims: orientations 5-8 swap width/height.
let (ow, oh) = if swaps_wh(orientation) { (h, w) } else { (w, h) };
let map = |x: usize, y: usize| -> (usize, usize) {
let (w, h) = (w as usize, h as usize);
match orientation {
1 => (x, y), // normal
2 => (w - 1 - x, y), // mirror horizontal
3 => (w - 1 - x, h - 1 - y), // 180
4 => (x, h - 1 - y), // mirror vertical
5 => (y, x), // transpose (main diagonal)
6 => (h - 1 - y, x), // 90 CW
7 => (h - 1 - y, w - 1 - x), // transverse (anti-diagonal)
8 => (y, w - 1 - x), // 90 CCW
_ => (x, y),
}
};
let mut out = vec![0u8; 3 * ow as usize * oh as usize];
for y in 0..h as usize {
for x in 0..w as usize {
let s = 3 * (y * w as usize + x);
let (dx, dy) = map(x, y);
let d = 3 * (dy * ow as usize + dx);
out[d..d + 3].copy_from_slice(&rgb[s..s + 3]);
}
}
(out, ow, oh)
}
/// Fit into the `target` box (aspect preserved, no upscaling).
fn fit_to_target(
rgb: Vec<u8>,
w: u32,
h: u32,
target: u32,
src_w: u32,
src_h: u32,
orientation: u8,
) -> Thumb {
let (w, h, rgb) = if w.max(h) > target {
let (nw, nh) = if w >= h {
(target, (h as f64 * target as f64 / w as f64).max(1.0).round() as u32)
} else {
((w as f64 * target as f64 / h as f64).max(1.0).round() as u32, target)
};
let buf = ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(w, h, rgb)
.expect("buffer size matches dims");
let r = imageops::resize(&buf, nw, nh, FilterType::Triangle);
(r.width(), r.height(), r.into_raw())
} else {
(w, h, rgb)
};
Thumb { rgb, width: w, height: h, src_width: src_w, src_height: src_h, orientation }
}
#[cfg(test)]
mod tests {
use super::*;
fn gradient(w: u32, h: u32) -> (Vec<u8>, u32, u32) {
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for y in 0..h {
for x in 0..w {
rgb.push((x * 255 / w.saturating_sub(1).max(1)) as u8);
rgb.push((y * 255 / h.saturating_sub(1).max(1)) as u8);
rgb.push(((x + y) as f32 * 127.5 / (w + h) as f32) as u8);
}
}
(rgb, w, h)
}
fn pixel(rgb: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8) {
let i = 3 * (y as usize * w as usize + x as usize);
(rgb[i], rgb[i + 1], rgb[i + 2])
}
#[test]
fn orientation_90_swaps_dims_and_moves_corner() {
// 100x50, orientation 6 (rotate 90 CW): top-left (0,0) goes to top-right.
let (rgb, w, h) = gradient(100, 50);
let tl = pixel(&rgb, w, 0, 0);
let (rot, nw, nh) = apply_orientation(rgb, w, h, 6);
assert_eq!((nw, nh), (50, 100));
assert_eq!(pixel(&rot, nw, nw - 1, 0), tl);
}
#[test]
fn orientation_all_eight_preserve_pixel_count() {
let (rgb, w, h) = gradient(37, 23);
for o in 1..=8 {
let (out, nw, nh) = apply_orientation(rgb.clone(), w, h, o);
assert_eq!(out.len(), 3 * 37 * 23);
assert_eq!((nw * nh), (w * h));
let swapped = swaps_wh(o);
assert_eq!(swapped, (nw, nh) == (h, w));
}
}
#[test]
fn pick_scale_never_below_target() {
assert_eq!(pick_scale(8000, 500), ScalingFactor::new(1, 8));
assert_eq!(pick_scale(4000, 500), ScalingFactor::new(1, 8));
assert_eq!(pick_scale(2000, 500), ScalingFactor::new(1, 4));
assert_eq!(pick_scale(1200, 500), ScalingFactor::new(1, 2));
assert_eq!(pick_scale(600, 500), ScalingFactor::ONE);
assert_eq!(pick_scale(300, 500), ScalingFactor::ONE); // no upscaling
}
#[test]
fn make_thumb_jpeg_roundtrip() {
// Encode a 2000x1500 gradient JPEG, thumbnail it, check the result.
let (rgb, w, h) = gradient(2000, 1500);
let jpeg = encode_jpeg(&rgb, w, h, 90).unwrap();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.jpg");
std::fs::write(&p, &jpeg).unwrap();
let t = make_thumb(&p, 500).unwrap();
assert_eq!((t.src_width, t.src_height), (2000, 1500));
assert_eq!(t.width, 500);
assert!((t.height as f32 - 375.0).abs() < 2.0); // 1500*500/2000
assert_eq!(t.rgb.len(), 3 * t.width as usize * t.height as usize);
}
#[test]
fn decode_jpeg_cache_hit_path() {
let (rgb, w, h) = gradient(320, 240);
let jpeg = encode_jpeg(&rgb, w, h, 90).unwrap();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.jpg");
std::fs::write(&p, &jpeg).unwrap();
let t = decode_jpeg(&p).unwrap();
assert_eq!((t.width, t.height), (w, h));
assert_eq!(t.rgb.len(), 3 * (w * h) as usize);
}
#[test]
fn make_thumb_exif_orientation() {
// Build a 400x200 landscape gradient, embed EXIF Orientation=6
// (rotate 90 CW), and check the thumbnail is portrait.
use exif::{experimental::Writer, Field, In, Tag, Value};
let (rgb, w, h) = gradient(400, 200);
let jpeg = encode_jpeg(&rgb, w, h, 90).unwrap();
let mut cursor = std::io::Cursor::new(Vec::new());
let field = Field { tag: Tag::Orientation, ifd_num: In::PRIMARY, value: Value::Short(vec![6]) };
let mut writer = Writer::new();
writer.push_field(&field);
writer.write(&mut cursor, true).unwrap();
let tiff = cursor.into_inner();
// Splice a proper APP1 ("Exif\0\0" + TIFF) segment after the SOI.
let mut app1 = vec![0xFF, 0xE1];
// JPEG APP1 length field includes its own 2 bytes + "Exif\0\0" (6) + TIFF.
let len = (tiff.len() + 8) as u16;
app1.extend_from_slice(&len.to_be_bytes());
app1.extend_from_slice(b"Exif\0\0");
app1.extend_from_slice(&tiff);
let mut with_exif = vec![0xFF, 0xD8];
with_exif.extend_from_slice(&app1);
with_exif.extend_from_slice(&jpeg[2..]);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("o.jpg");
std::fs::write(&p, &with_exif).unwrap();
assert_eq!(exif_orientation(&with_exif), 6);
let t = make_thumb(&p, 500).unwrap();
// 400x200 landscape + orientation 6 -> 200x400 portrait (no upscale: 500 box).
assert_eq!((t.src_width, t.src_height), (200, 400));
assert_eq!((t.width, t.height), (200, 400));
}
}

View File

@ -1,19 +1,104 @@
//! Gallery app logic: scan (M1) + thumbnail engine (M2).
//!
//! Threading model:
//! - scan thread: one per launch, walks storage, owns the scan DB connection.
//! - thumb engine: process-wide pool (dispatcher + N workers), created on the
//! UI thread once; workers own one DB connection each (WAL).
//! - UI thread: owns the window + model; drains engine results 10x/s via a
//! Slint timer; builds `slint::Image` here (Image is !Send, pixel bytes are).
slint::include_modules!(); slint::include_modules!();
pub mod db; pub mod db;
pub mod images;
pub mod scan; pub mod scan;
pub mod thumb;
use slint::{ComponentHandle, Model, SharedString, VecModel}; use std::collections::HashSet;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
/// Fill the window's list model from `paths` and set the status line. use slint::{
fn set_list(weak: &slint::Weak<Main>, paths: Vec<String>, status: String) { ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, SharedPixelBuffer, SharedString, Timer,
TimerMode, VecModel,
};
use crate::scan::Entry;
use crate::thumb::{Engine, Job, LIST_TARGET};
/// Rows to submit to the pool whenever the list is (re)painted: the top of
/// the "newest" order, i.e. what the user actually sees first.
const PREFETCH_FIRST: usize = 200;
/// Rows on each side of the visible range that scroll-prefetch keeps ready.
const SCROLL_MARGIN: usize = 50;
/// Row height must match main.slint.
const ROW_HEIGHT: f32 = 36.0;
const DRAIN_INTERVAL_MS: u64 = 100;
static STATE: OnceLock<AppState> = OnceLock::new();
struct AppState {
engine: Engine,
/// Current list (newest first); source for scroll prefetch. UI thread only.
entries: Mutex<Vec<Entry>>,
/// Paths already handed to the pool for the current list. UI thread only.
submitted: Mutex<HashSet<String>>,
/// Total thumbnails painted; drives the acceptance log cadence.
painted: AtomicU64,
}
/// Create the pool + cache dir once per process.
fn state() -> &'static AppState {
STATE.get_or_init(|| {
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
let cache_dir = base.join("thumbs");
let workers =
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
let engine = Engine::new(cache_dir, workers, LIST_TARGET).expect("thumb engine");
log::info!(
"gallery: thumb engine up: {} workers, target {} px, cache {}",
workers,
LIST_TARGET,
engine.cache_dir().display()
);
AppState {
engine,
entries: Mutex::new(Vec::new()),
submitted: Mutex::new(HashSet::new()),
painted: AtomicU64::new(0),
}
})
}
/// Replace the list model (UI thread) + set the status line. A model
/// replacement invalidates in-flight work for the previous list: bump the
/// generation and clear the submitted set so repaints re-prefetch.
/// `do_prefetch` is false for cold-scan streaming batches (nothing to show
/// yet — re-submitting the top rows every batch would waste decode work).
fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String, do_prefetch: bool) {
let weak = weak.clone(); let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || { let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() { let Some(app) = STATE.get() else { return };
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>()); app.engine.bump_generation();
app.submitted.lock().unwrap().clear();
*app.entries.lock().unwrap() = entries.clone();
let items: Vec<Item> = entries
.iter()
.map(|e| Item {
path: SharedString::from(e.path.clone()),
image: Image::default(),
})
.collect();
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
m.set_vec(items);
} }
w.set_status(status.into()); w.set_status(status.into());
if do_prefetch {
// Top of the list starts filling now (cold: decodes; warm: cache hits).
prefetch(app, 0..PREFETCH_FIRST);
}
}); });
} }
@ -27,69 +112,174 @@ fn set_status(weak: &slint::Weak<Main>, status: String) {
}); });
} }
/// M1: metadata from sqlite paints the list immediately (first frame < 100 ms /// Submit not-yet-submitted entries in `range` (row indexes) to the pool.
/// per HANDOFF §8); a background walk then refreshes the DB and the list. /// UI thread only.
/// UI updates are posted to the event loop — the scan thread never touches the fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
/// window directly. Called from both entry points after `Main::new()`. if range.is_empty() {
pub fn start_scan(window: &Main) { return;
let model = slint::ModelRc::new(VecModel::<SharedString>::default()); }
window.set_items(model); let entries = app.entries.lock().unwrap();
let weak = window.as_weak(); let start = range.start.min(entries.len());
let end = range.end.min(entries.len());
if start >= end {
return;
}
let gen = app.engine.generation();
let mut submitted = app.submitted.lock().unwrap();
let jobs: Vec<Job> = entries[start..end]
.iter()
.enumerate()
.filter_map(|(i, e)| {
submitted
.insert(e.path.clone())
.then_some(Job {
id: start + i,
path: e.path.clone(),
mtime: e.mtime,
size: e.size,
gen,
})
})
.collect();
if !jobs.is_empty() {
app.engine.submit(&jobs);
}
}
std::thread::spawn(move || { /// Scroll-driven prefetch (from main.slint `scrolled => on-scroll-changed`).
let mut db = match db::open(&db::db_path()) { /// content-y is negative while scrolled down.
Ok(db) => db, fn on_scroll(app: &AppState, y: f32, height: f32) {
Err(e) => { let first = (y.abs() / ROW_HEIGHT) as usize;
eprintln!("gallery: db error: {e}"); let visible = (height / ROW_HEIGHT) as usize;
set_list(&weak, Vec::new(), format!("db error: {e}")); prefetch(app, first.saturating_sub(SCROLL_MARGIN)..first + visible + SCROLL_MARGIN);
}
/// 10x/s: take finished thumbnails, guard against list resets, paint rows.
/// Slint 1.18 timers stop when dropped, and Timer is !Send (UI thread only):
/// we leak the one handle for the process lifetime (8 bytes).
fn start_drain_timer(weak: &slint::Weak<Main>) {
let weak = weak.clone();
let timer = Timer::default();
timer.start(
TimerMode::Repeated,
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
move || {
let Some(app) = STATE.get() else { return };
let results = app.engine.drain();
if results.is_empty() {
return; return;
} }
}; let Some(w) = weak.upgrade() else { return };
let model = w.get_items();
// 1. Instant first paint from the cached DB (relaunch path). let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
let cached = db::list_paths(&mut db).unwrap_or_default(); return
let n0 = cached.len(); };
let db_warm = n0 > 0; let entries = app.entries.lock().unwrap();
if db_warm { let mut painted = 0usize;
set_list(&weak, cached, format!("{n0} files (cached)")); for r in &results {
} // Guard: list may have been reset between submit and delivery.
if !r.ok
// 2. Background rescan: detect new/changed/removed files. || r.id >= m.row_count()
// Cold start: stream results into the list as they're found, so the || r.id >= entries.len()
// UI fills in while the scan runs (never waits for the full DB). || entries[r.id].path != r.path
// Warm start: keep the cached list, only update the status line. {
let t0 = std::time::Instant::now(); continue;
let roots = scan::roots(); }
let entries = scan::walk_stream(&roots, &|batch| { let mut buffer = SharedPixelBuffer::<Rgb8Pixel>::new(r.width, r.height);
let n = batch.len(); buffer.make_mut_bytes().copy_from_slice(&r.rgb);
if db_warm { let image = Image::from_rgb8(buffer);
set_status(&weak, format!("Updating… {n}")); painted += 1;
} else { m.set_row_data(
let mut sorted = batch.to_vec(); r.id,
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path))); Item {
set_list( path: SharedString::from(r.path.clone()),
&weak, image,
sorted.into_iter().map(|e| e.path).collect(), },
format!("Scanning… {n}"),
); );
} }
}); if painted > 0 {
let total =
app.painted.fetch_add(painted as u64, Ordering::Relaxed) + painted as u64;
// Acceptance log: first screen, then a steady heartbeat. Watch
// the queue not grow while scrolling (HANDOFF §8).
if total <= 48 || total % 512 < painted as u64 {
log::info!(
"gallery: {} thumbs painted, {}",
total,
app.engine.log_summary()
);
}
}
},
);
std::mem::forget(timer);
}
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| { /// M1: cached DB paints instantly on relaunch; a background walk refreshes it
eprintln!("gallery: db apply error: {e}"); /// (streaming into the list on cold start). M2: the thumbnail pool fills rows
(0, 0, 0) /// as they appear — top rows on paint, the visible range on scroll.
}); pub fn start_scan(window: &Main) {
let paths = db::list_paths(&mut db).unwrap_or_default(); let model = ModelRc::new(VecModel::<Item>::default());
let n = paths.len(); window.set_items(model);
let ms = t0.elapsed().as_millis(); let weak = window.as_weak();
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms"); let app = state();
set_list(&weak, paths, format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms")); window.on_scroll_changed(move |y: f32, h: f32| on_scroll(app, y, h));
start_drain_timer(&weak);
std::thread::spawn(move || {
scan_thread(&weak);
}); });
} }
#[cfg(target_os = "android")] fn scan_thread(weak: &slint::Weak<Main>) {
use std::sync::OnceLock; let mut db = match db::open(&db::db_path()) {
Ok(db) => db,
Err(e) => {
log::error!("gallery: db error: {e}");
set_list(weak, Vec::new(), format!("db error: {e}"), false);
return;
}
};
// 1. Instant first paint from the cached DB (relaunch path).
let cached = db::list_entries(&mut db).unwrap_or_default();
let db_warm = !cached.is_empty();
if db_warm {
let n0 = cached.len();
set_list(weak, cached, format!("{n0} files (cached)"), true);
}
// 2. Background rescan: detect new/changed/removed files.
// Cold start: stream results into the list as they're found, so the
// UI fills in while the scan runs (never waits for the full DB).
// Warm start: keep the cached list, only update the status line.
let t0 = std::time::Instant::now();
let roots = scan::roots();
let entries = scan::walk_stream(&roots, &|batch| {
let n = batch.len();
if db_warm {
set_status(weak, format!("Updating… {n}"));
} else {
let mut sorted = batch.to_vec();
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
set_list(weak, sorted, format!("Scanning… {n}"), false);
}
});
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
log::error!("gallery: db apply error: {e}");
(0, 0, 0)
});
let entries = db::list_entries(&mut db).unwrap_or_default();
let n = entries.len();
let ms = t0.elapsed().as_millis();
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
// Final list: set_list bumps the generation + re-prefetches the top rows.
// (Warm: those are disk-cache hits, so the repaint is nearly free.)
set_list(weak, entries, format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms"), true);
}
/// A clone of `AndroidApp` kept for the app's whole lifetime so the Java side /// A clone of `AndroidApp` kept for the app's whole lifetime so the Java side
/// stays reachable from ANY thread after Slint has consumed the original: /// stays reachable from ANY thread after Slint has consumed the original:

View File

@ -1,7 +1,23 @@
use gallery::Main; use gallery::Main;
use slint::ComponentHandle; use slint::ComponentHandle;
/// Minimal eprintln logger for desktop dev (Android uses android_logger).
struct Stdout;
impl log::Log for Stdout {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
_metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
eprintln!("[{}] {}", record.metadata().level(), record.args());
}
}
fn flush(&self) {}
}
fn main() -> Result<(), slint::PlatformError> { fn main() -> Result<(), slint::PlatformError> {
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
let main_window = Main::new()?; let main_window = Main::new()?;
gallery::start_scan(&main_window); gallery::start_scan(&main_window);
main_window.show()?; main_window.show()?;

View File

@ -1,43 +1,70 @@
// Gallery — M1: metadata scan + placeholder list. // Gallery — M2: metadata list with streaming thumbnails.
// The real grid (image cells) arrives with M3 per HANDOFF.md. // Rows are 36px: 28px thumbnail (image-fit: cover) + path text.
// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll); the grid (M3)
// replaces the row body with square image cells.
import { ListView, VerticalBox } from "std-widgets.slint"; import { ListView, VerticalBox } from "std-widgets.slint";
export struct Item {
path: string,
image: image,
}
export component Main inherits Window { export component Main inherits Window {
title: "Gallery"; title: "Gallery";
/// Scan/DB status line ("Scanning… 1234", "5123 files (42+ 3~ 1–) in 380 ms"). /// Fired by the ListView on every scroll step: (content-y, list height).
in property <string> status: "Scanning…"; /// content-y is negative while scrolled down. (Rust setter: on_scroll_changed)
/// Scanned file paths, newest first (M1: shown as text rows). callback scroll-changed(y: length, height: length);
in property <[string]> items;
/// Scan/DB status line.
in property <string> status: "Starting…";
/// Rows, newest first. `image` starts empty and fills as the thumbnail
/// pool delivers results.
in property <[Item]> items;
VerticalBox { VerticalBox {
width: 100%; width: 100%;
spacing: 12px; spacing: 8px;
padding: 24px; padding: 12px;
// Respect status-bar / nav-bar cutouts (built into the Slint window // Respect status-bar / nav-bar cutouts (built into the Slint window
// on Android via safe-area-insets). // on Android via safe-area-insets).
padding-top: 24px + root.safe-area-insets.top; padding-top: 12px + root.safe-area-insets.top;
padding-bottom: 24px + root.safe-area-insets.bottom; padding-bottom: 12px + root.safe-area-insets.bottom;
Text { Text {
text: "Gallery"; text: root.status;
font-size: 24px; font-size: 14px;
font-weight: 600; color: #8ab4f8;
overflow: elide;
} }
Text { text: "\{status}"; font-size: 14px; }
// M1: paths as text. M3 replaces the row body with square image cells.
ListView { ListView {
width: 100%; width: 100%;
vertical-stretch: 1; vertical-stretch: 1;
for path[index] in root.items: Rectangle { // Scroll-driven prefetch: keep ~SCROLL_MARGIN rows on each side
height: 28px; // of the visible range submitted to the pool (see lib.rs).
scrolled => {
root.scroll-changed(self.content-y, self.visible-height);
}
for item[index] in root.items: Rectangle {
height: 36px;
width: parent.width; width: parent.width;
background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424; background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424;
border-radius: 4px; border-radius: 4px;
Image {
x: 4px;
y: 4px;
width: 28px;
height: 28px;
source: item.image;
image-fit: cover;
}
Text { Text {
x: 8px; x: 38px;
text: path; y: 0;
width: parent.width - 46px;
height: 100%;
text: item.path;
font-size: 13px; font-size: 13px;
vertical-alignment: center; vertical-alignment: center;
overflow: elide; overflow: elide;

458
src/thumb.rs Normal file
View File

@ -0,0 +1,458 @@
//! Thumbnail engine (M2): worker pool, disk cache, cancellation, timing logs.
//!
//! ```text
//! UI ──submit──> dispatcher ──round-robin──> N workers ──results──> UI
//! worker:
//! 1. disk cache hit? thumbs/{sha1(path|mtime|size)}.jpg → decode, done
//! 2. else: shrink-decode → orient → fit → encode → cache (atomic rename)
//! 3. generation check → deliver (id, rgba8) — the UI thread builds the
//! slint::Image (Image is !Send, pixel bytes are not)
//! ```
//!
//! Cancellation is a process-wide generation counter (`AtomicU64`): the UI
//! bumps it whenever the list is reset (new scan); workers that finish a job
//! from a stale generation keep the disk-cache write (content-keyed, still
//! valid) but drop the UI delivery. The queue is unbounded on purpose —
//! backlog is visible in the timing logs (acceptance: it must not grow while
//! scrolling).
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use sha1::{Digest, Sha1};
use crate::db;
use crate::images;
/// M2 list thumbnails: 28 px rows x 2 (Retina). The M3 grid will ask for
/// 500 px cells (HANDOFF §4) — the target is part of the cache key, so both
/// sizes can coexist.
pub const LIST_TARGET: u32 = 64;
const JPEG_QUALITY: u8 = 85;
#[derive(Debug, Clone)]
pub struct Job {
/// Row index in the UI model at submit time.
pub id: usize,
pub path: String,
pub mtime: i64,
pub size: i64,
/// Generation the job belongs to (stale jobs are not delivered).
pub gen: u64,
}
#[derive(Debug)]
pub struct JobResult {
pub id: usize,
pub gen: u64,
pub path: String,
pub ok: bool,
/// RGB8, orientation applied, fitted to the engine's target size.
pub rgb: Vec<u8>,
pub width: u32,
pub height: u32,
pub from_cache: bool,
pub elapsed_ms: u128,
}
pub struct Engine {
tx: mpsc::Sender<Job>,
/// Only the UI thread drains; the Mutex just makes Engine Sync so it can
/// live in a process-wide OnceLock.
rx: Mutex<mpsc::Receiver<JobResult>>,
gen: Arc<AtomicU64>,
cache_dir: PathBuf,
in_flight: Arc<AtomicUsize>,
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
target: u32,
}
/// Cache file for a job: `thumbs/{sha1(path|mtime|size|target)}.jpg` — the
/// target is in the key so different render sizes coexist (M2 list 64 px,
/// M3 grid 500 px).
pub fn cache_path(cache_dir: &Path, path: &str, mtime: i64, size: i64, target: u32) -> PathBuf {
let mut h = Sha1::new();
h.update(path.as_bytes());
h.update(b"|");
h.update(mtime.to_le_bytes());
h.update(b"|");
h.update(size.to_le_bytes());
h.update(b"|");
h.update(target.to_le_bytes());
let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
cache_dir.join(format!("{hex}.jpg"))
}
impl Engine {
/// Spawn the dispatcher + `workers` worker threads. All jobs render at
/// `target` px (longest side).
pub fn new(cache_dir: PathBuf, workers: usize, target: u32) -> Result<Self, String> {
fs::create_dir_all(&cache_dir).map_err(|e| format!("mkdir {}: {e}", cache_dir.display()))?;
let (job_tx, job_rx) = mpsc::channel::<Job>();
let (res_tx, res_rx) = mpsc::channel::<JobResult>();
let gen = Arc::new(AtomicU64::new(1));
let in_flight = Arc::new(AtomicUsize::new(0));
let stats = Stats {
done: Arc::new(AtomicU64::new(0)),
hits: Arc::new(AtomicU64::new(0)),
errs: Arc::new(AtomicU64::new(0)),
};
let mut workers_tx = Vec::with_capacity(workers);
for i in 0..workers {
let (wtx, wrx) = mpsc::channel::<Job>();
let (res_tx, gen, in_flight) = (res_tx.clone(), gen.clone(), in_flight.clone());
let (done, hits, errs) = (stats.done.clone(), stats.hits.clone(), stats.errs.clone());
let cache_dir = cache_dir.clone();
std::thread::Builder::new()
.name(format!("thumb-{i}"))
.spawn(move || {
worker_loop(wrx, res_tx, gen, in_flight, done, hits, errs, cache_dir, target)
})
.map_err(|e| e.to_string())?;
workers_tx.push(wtx);
}
drop(res_tx);
// Dispatcher: single receiver for std::mpsc, round-robins to workers.
std::thread::Builder::new()
.name("thumb-dispatch".into())
.spawn(move || {
let mut next = 0usize;
for job in job_rx {
for _ in 0..workers_tx.len() {
if workers_tx[next % workers_tx.len()].send(job.clone()).is_ok() {
next += 1;
break;
}
next += 1;
}
}
})
.map_err(|e| e.to_string())?;
Ok(Self {
tx: job_tx,
rx: Mutex::new(res_rx),
gen,
cache_dir,
in_flight,
done: stats.done,
hits: stats.hits,
errs: stats.errs,
target,
})
}
pub fn target(&self) -> u32 {
self.target
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn generation(&self) -> u64 {
self.gen.load(Ordering::Relaxed)
}
/// Invalidate all in-flight/pending work (list reset).
pub fn bump_generation(&self) -> u64 {
self.gen.fetch_add(1, Ordering::Relaxed) + 1
}
pub fn submit(&self, jobs: &[Job]) {
for j in jobs {
self.in_flight.fetch_add(1, Ordering::Relaxed);
if self.tx.send(j.clone()).is_err() {
self.in_flight.fetch_sub(1, Ordering::Relaxed);
break; // engine gone
}
}
}
/// Non-blocking drain (UI thread). Stale-generation results are dropped.
pub fn drain(&self) -> Vec<JobResult> {
let mut out = Vec::new();
let gen = self.generation();
let rx = self.rx.lock().unwrap();
while let Ok(r) = rx.try_recv() {
if r.gen == gen {
out.push(r);
}
}
out
}
/// "queued" = accepted but not yet finished.
pub fn queued(&self) -> usize {
self.in_flight.load(Ordering::Relaxed)
}
pub fn log_summary(&self) -> String {
format!(
"thumbs: {} done ({} cache hit, {} failed), {} queued",
self.done.load(Ordering::Relaxed),
self.hits.load(Ordering::Relaxed),
self.errs.load(Ordering::Relaxed),
self.queued(),
)
}
}
#[derive(Clone)]
struct Stats {
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
}
fn worker_loop(
rx: mpsc::Receiver<Job>,
res_tx: mpsc::Sender<JobResult>,
gen: Arc<AtomicU64>,
in_flight: Arc<AtomicUsize>,
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
cache_dir: PathBuf,
target: u32,
) {
// One WAL connection per worker; the scan thread keeps its own.
let mut conn = match db::open(&db::db_path()) {
Ok(c) => Some(c),
Err(e) => {
log::warn!("gallery: thumb worker db error: {e}");
None
}
};
loop {
let job = match rx.recv() {
Ok(j) => j,
Err(_) => return, // channel closed
};
let t0 = Instant::now();
let (ok, thumb, from_cache) = process(&job, &cache_dir, target, &hits, &errs);
let elapsed = t0.elapsed().as_millis();
if let Some(ref mut c) = conn {
if let Some(ref t) = thumb {
if let Err(e) = db::set_thumb(
c,
&job.path,
t.src_width as i64,
t.src_height as i64,
t.orientation as i64,
cache_path(&cache_dir, &job.path, job.mtime, job.size, target)
.to_str()
.unwrap_or(""),
) {
log::warn!("gallery: db set_thumb failed for {}: {e}", job.path);
}
}
}
in_flight.fetch_sub(1, Ordering::Relaxed);
let n = done.fetch_add(1, Ordering::Relaxed) + 1;
if n % 256 == 0 {
log::info!(
"gallery: thumbs {} ({} hit, {} err, {} queued, last {} ms)",
n,
hits.load(Ordering::Relaxed),
errs.load(Ordering::Relaxed),
in_flight.load(Ordering::Relaxed),
elapsed,
);
}
// Stale generation: cache write stays (content-keyed), skip delivery.
if gen.load(Ordering::Relaxed) != job.gen {
continue;
}
let (rgb, width, height) = match thumb {
Some(t) => (t.rgb, t.width, t.height),
None => (Vec::new(), 0, 0),
};
let _ = res_tx.send(JobResult {
id: job.id,
gen: job.gen,
path: job.path,
ok,
rgb,
width,
height,
from_cache,
elapsed_ms: elapsed,
});
}
}
fn process(
job: &Job,
cache_dir: &Path,
target: u32,
hits: &Arc<AtomicU64>,
errs: &Arc<AtomicU64>,
) -> (bool, Option<images::Thumb>, bool) {
let cache = cache_path(cache_dir, &job.path, job.mtime, job.size, target);
if cache.exists() {
if let Ok(t) = images::decode_jpeg(&cache) {
hits.fetch_add(1, Ordering::Relaxed);
return (true, Some(t), true);
}
// Corrupt cache entry: regenerate.
let _ = fs::remove_file(&cache);
}
match images::make_thumb(Path::new(&job.path), target) {
Ok(t) => {
match images::encode_jpeg(&t.rgb, t.width, t.height, JPEG_QUALITY) {
Ok(bytes) => {
let tmp = cache.with_extension("tmp");
if fs::write(&tmp, &bytes).is_ok() {
if let Err(e) = fs::rename(&tmp, &cache) {
log::warn!("gallery: cache rename failed: {e}");
let _ = fs::remove_file(&tmp);
}
}
}
Err(e) => log::warn!("gallery: cache encode failed for {}: {e}", job.path),
}
(true, Some(t), false)
}
Err(e) => {
errs.fetch_add(1, Ordering::Relaxed);
log::warn!("gallery: thumb failed for {}: {e}", job.path);
(false, None, false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::images;
use crate::scan::Entry;
fn make_jpeg(w: u32, h: u32, r: u8) -> Vec<u8> {
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for _ in 0..w * h as u32 {
rgb.extend_from_slice(&[r, 120, 200]);
}
images::encode_jpeg(&rgb, w, h, 90).unwrap()
}
/// Synthetic 5k-set engine test (HANDOFF §8/§9 M2): cold fill, timing
/// logs, instant warm refill from the disk cache, generation cancellation.
#[test]
fn engine_5k_cold_warm_cancel() {
const N: usize = 5000;
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("thumbs");
// Keep the worker threads' DB writes out of the dev database.
// (Only this test touches the DB path; the others pass explicit tempdirs.)
std::env::set_var("GALLERY_DB_PATH", dir.path().join("gallery.db"));
// 1. Generate the synthetic set (varied sizes, a few "large").
let entries = (0..N).map(|i| {
let big = i % 500 == 0; // every 500th is a "large" photo
let (w, h) = if big {
(4000u32, 3000u32)
} else {
(600u32 + (i % 8) as u32 * 200, 400u32 + (i % 6) as u32 * 150)
};
let p = dir.path().join(format!("p{i:05}.jpg"));
std::fs::write(&p, make_jpeg(w, h, (i % 250) as u8 + 2)).unwrap();
Entry { path: p.to_str().unwrap().into(), mtime: i as i64, size: std::fs::metadata(&p).unwrap().len() as i64 }
}).collect::<Vec<_>>();
// 2. Cold run.
let eng = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap();
let gen = eng.generation();
let t0 = Instant::now();
eng.submit(&entries.iter().enumerate().map(|(i, e)| Job {
id: i, path: e.path.clone(), mtime: e.mtime, size: e.size, gen,
}).collect::<Vec<_>>());
let mut first_48 = None;
let mut got = 0usize;
let mut fails = 0;
let mut cache_hits = 0;
while got < N {
std::thread::sleep(std::time::Duration::from_millis(5));
for r in eng.drain() {
got += 1;
if r.from_cache { cache_hits += 1; }
if !r.ok { fails += 1; }
if first_48.is_none() && got >= 48 { first_48 = Some(t0.elapsed()); }
}
}
let cold_total = t0.elapsed();
assert_eq!(fails, 0, "all 5k synthetic thumbnails must decode");
assert_eq!(cache_hits, 0);
assert_eq!(eng.queued(), 0);
assert!(first_48.is_some());
eprintln!("gallery test: cold {N} thumbs in {cold_total:?}, first 48 in {first_48:?}");
assert_eq!(std::fs::read_dir(&cache).unwrap().count(), N, "cache holds all entries");
// 3. Warm run: everything must come from the disk cache.
let eng2 = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap();
let gen2 = eng2.generation();
let t0 = Instant::now();
eng2.submit(&entries.iter().enumerate().map(|(i, e)| Job {
id: i, path: e.path.clone(), mtime: e.mtime, size: e.size, gen: gen2,
}).collect::<Vec<_>>());
let mut got = 0usize;
let mut cache_hits = 0;
while got < N {
std::thread::sleep(std::time::Duration::from_millis(5));
for r in eng2.drain() {
got += 1;
assert!(r.ok);
if r.from_cache { cache_hits += 1; }
}
}
let warm_total = t0.elapsed();
assert_eq!(cache_hits, N, "warm run is 100% cache hits");
eprintln!("gallery test: warm {N} thumbs in {warm_total:?} (100% cache)");
assert!(warm_total.as_secs_f64() < cold_total.as_secs_f64());
// 4. Cancellation: submit, bump generation before draining -> dropped.
let eng3 = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap();
let stale = eng3.generation();
let p = &entries[0];
// Use a fresh path (mtime bump) so it is a cache miss and takes time.
let fresh = dir.path().join("fresh.jpg");
std::fs::write(&fresh, make_jpeg(3000, 2000, 99)).unwrap();
let sz = std::fs::metadata(&fresh).unwrap().len() as i64;
eng3.submit(&[Job { id: 0, path: fresh.to_str().unwrap().into(), mtime: 1, size: sz, gen: stale }]);
std::thread::sleep(std::time::Duration::from_millis(20));
let new_gen = eng3.bump_generation();
assert!(new_gen != stale);
// Drain until the (now stale) result is gone: nothing must arrive.
std::thread::sleep(std::time::Duration::from_millis(2000));
assert!(eng3.drain().is_empty(), "stale-generation results are dropped");
assert_eq!(eng3.queued(), 0);
let _ = p;
}
#[test]
fn cache_key_stable_and_distinct() {
let d = Path::new("/tmp/x");
let a = cache_path(d, "/a.jpg", 1, 2, 64);
let b = cache_path(d, "/a.jpg", 1, 2, 64);
let c = cache_path(d, "/a.jpg", 3, 2, 64);
let e = cache_path(d, "/a.jpg", 1, 2, 500); // different render size
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(a, e);
assert_eq!(a.file_name().unwrap().to_str().unwrap().len(), 40 + 4); // sha1 hex + .jpg
}
}