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"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.7.0"
@ -558,6 +564,15 @@ dependencies = [
"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]]
name = "block2"
version = "0.5.1"
@ -771,6 +786,15 @@ dependencies = [
"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]]
name = "codespan-reporting"
version = "0.13.1"
@ -826,6 +850,12 @@ dependencies = [
"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]]
name = "convert_case"
version = "0.10.0"
@ -881,6 +911,15 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
@ -945,6 +984,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ctor"
version = "0.10.1"
@ -989,6 +1037,17 @@ dependencies = [
"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]]
name = "dispatch"
version = "0.2.0"
@ -1495,11 +1554,15 @@ name = "gallery"
version = "0.1.0"
dependencies = [
"android_logger",
"image",
"kamadak-exif",
"log",
"rusqlite",
"sha1",
"slint",
"slint-build",
"tempfile",
"turbojpeg",
]
[[package]]
@ -1524,6 +1587,12 @@ dependencies = [
"libc",
]
[[package]]
name = "gcd"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a"
[[package]]
name = "gethostname"
version = "1.1.0"
@ -1780,6 +1849,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "i-slint-backend-android-activity"
version = "1.18.1"
@ -2506,6 +2584,15 @@ dependencies = [
"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]]
name = "keyboard-types"
version = "0.7.0"
@ -2807,6 +2894,12 @@ dependencies = [
"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]]
name = "naga"
version = "30.0.1"
@ -4067,6 +4160,17 @@ dependencies = [
"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]]
name = "shlex"
version = "1.3.0"
@ -4747,6 +4851,30 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "typed-index-collections"
version = "3.5.0"
@ -4757,6 +4885,12 @@ dependencies = [
"serde",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "udev"
version = "0.9.3"

View File

@ -11,6 +11,10 @@ slint = { version = "1.18.1", features = ["backend-android-activity-06"] }
log = "0.4"
android_logger = { version = "0.14", target-family = "android" }
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]
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
rediscovered.
**Status: M0 done** — this scaffold builds and runs on the emulator
(`./scripts/build.sh debug`), including the permission-aware Android init and
the lifecycle-event hook.
**Status: M2 done (2026-10-27)** — M0 scaffold, M1 metadata scan + sqlite +
instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink decode, worker
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/
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.slint # screens (split into ui/ components at M3) [exists]
db.rs # sqlite schema + scan (M1)
scan.rs # directory walk, image detection, metadata read (M1)
thumb.rs # pool, decode@size, disk cache, cancellation (M2)
images.rs # decode dispatch (zune-jpeg / imagepipe / png) (M2)
ui.rs # Rust↔Slint glue: Model provider, thumb→Image (M3)
db.rs # sqlite schema + incremental scan apply [done M1]
scan.rs # directory walk, image detection, walk_stream [done M1]
thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
viewer.rs # full-size load + transform state (M4)
perm.rs # storage permission check/request (M5)
```
@ -138,11 +139,12 @@ Crate choices (all verified current on crates.io, 2026-10):
| crate | version | why |
|---|---|---|
| `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 |
| `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 |
| `image` | 0.25.x | PNG + header/metadata (`ImageReader::into_dimensions`, EXIF orientation) |
| `imagepipe` | 0.5.x (upd 2026-08) | HEIC/HEIF via Apple ImagePipe — **open question: Android NDK support** (see §10) |
| `slint::Image::from_rgba8_premultiplied` | — | how decoded thumbs enter the UI |
| `rusqlite` | 0.41, `bundled` feature | SQLite without system deps; cross-compiles to Android |
| `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, `default-features=false, png` | PNG thumbs + `imageops::resize` final fit |
| `kamadak-exif` | 0.6 | EXIF orientation tag (all 8 values applied as pixel maps); `experimental::Writer` used only in tests |
| `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.
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`.
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
or the app can't read them.
- **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache;
cancellation; pool; synthetic 5k test set; timing logs.
*Accept: first grid fill < 2 s; re-launch fills instantly from disk cache;
fast scroll causes no backlog (watch pending-queue length in logs).*
- **M2 — thumbnail engine (DONE, 2026-10-27)**: `images.rs` (turbojpeg
DCT-level shrink decode via 1/2/1/4/1/8 `ScalingFactor`, all 8 EXIF
orientations as uniform `(x,y)→(dx,dy)` pixel maps, `image::imageops::resize`
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,
pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery →
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
1. **HEIC on Android**: `imagepipe` is Apple's ImagePipe — its Rust crate
may only ship Apple prebuilts. **First task of M2: verify an NDK build**
(`cargo apk2 build` with imagepipe). Fallbacks: `libheif-rs`/libheif
(C, cross-buildable), or skip HEIC initially and document JPEG/PNG only
(the emulator test set is JPEG anyway; real device photos from this
ecosystem may already be JPEG — check passgo's existing files).
1. **HEIC (RESOLVED for now)**: the real device has 0 HEIC files (9109
JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
`images.rs` dispatch (libheif-rs / `image` heic feature / libvips);
the engine API is decoder-agnostic.
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
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
@ -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.
6. **rusqlite `bundled`** compiles SQLite from source in every target
(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

View File

@ -13,10 +13,33 @@ cd "$(dirname "$0")/.."
export ANDROID_HOME=$HOME/android-sdk
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).
# The `cc` crate picks these up per target triple (min sdk 26).
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 CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
@ -32,17 +55,23 @@ arg2=${2:-install}
profile=--release
case $arg1 in
debug) target=x86_64-linux-android; profile="" ;;
x86_64) target=x86_64-linux-android ;;
aarch64) target=aarch64-linux-android ;;
debug) target=x86_64-linux-android; profile="" ;;
debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;;
x86_64) target=x86_64-linux-android ;;
aarch64) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a ;;
*) echo "unknown arch: $arg1" >&2; exit 1 ;;
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
apk=$(ls target/*/apk/gallery.apk | head -1)
apk=target/$profile_dir/apk/gallery.apk
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
if [ "$arg2" = install ] && command -v adb >/dev/null; then
adb install -r "$apk"
adb shell am start -n wow.gmp.gallery/android.app.NativeActivity || true
"${adb[@]}" install -r "$apk"
"${adb[@]}" shell am start -n wow.gmp.gallery/android.app.NativeActivity || true
fi

View File

@ -39,6 +39,9 @@ fn android_data_dir() -> PathBuf {
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
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")]
let dir = android_data_dir();
#[cfg(not(target_os = "android"))]
@ -137,10 +140,12 @@ pub fn apply(
Ok((inserted, updated, deleted))
}
/// All known paths, newest first.
pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error> {
let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?;
let rows = st.query_map([], |r| r.get::<_, String>(0))?;
/// All known entries, newest first.
pub fn list_entries(conn: &mut Connection) -> Result<Vec<Entry>, rusqlite::Error> {
let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
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();
for row in rows {
out.push(row?);
@ -148,6 +153,23 @@ pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error>
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)]
mod tests {
use super::*;
@ -177,10 +199,31 @@ mod tests {
.unwrap();
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"]);
}
#[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]
fn scan_then_apply_roundtrip() {
let dir = tempfile::tempdir().unwrap();
@ -195,8 +238,8 @@ mod tests {
let (i, u, d) = apply(&mut conn, &entries).unwrap();
assert_eq!((i, u, d), (2, 0, 0));
let paths = list_paths(&mut conn).unwrap();
assert_eq!(paths.len(), 2);
assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap())));
let list = list_entries(&mut conn).unwrap();
assert_eq!(list.len(), 2);
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!();
pub mod db;
pub mod images;
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.
fn set_list(weak: &slint::Weak<Main>, paths: Vec<String>, status: String) {
use slint::{
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 _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() {
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>());
let Some(app) = STATE.get() else { return };
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());
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
/// per HANDOFF §8); a background walk then refreshes the DB and the list.
/// UI updates are posted to the event loop — the scan thread never touches the
/// window directly. Called from both entry points after `Main::new()`.
pub fn start_scan(window: &Main) {
let model = slint::ModelRc::new(VecModel::<SharedString>::default());
window.set_items(model);
let weak = window.as_weak();
/// Submit not-yet-submitted entries in `range` (row indexes) to the pool.
/// UI thread only.
fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
if range.is_empty() {
return;
}
let entries = app.entries.lock().unwrap();
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 || {
let mut db = match db::open(&db::db_path()) {
Ok(db) => db,
Err(e) => {
eprintln!("gallery: db error: {e}");
set_list(&weak, Vec::new(), format!("db error: {e}"));
/// Scroll-driven prefetch (from main.slint `scrolled => on-scroll-changed`).
/// content-y is negative while scrolled down.
fn on_scroll(app: &AppState, y: f32, height: f32) {
let first = (y.abs() / ROW_HEIGHT) as usize;
let visible = (height / ROW_HEIGHT) as usize;
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;
}
};
// 1. Instant first paint from the cached DB (relaunch path).
let cached = db::list_paths(&mut db).unwrap_or_default();
let n0 = cached.len();
let db_warm = n0 > 0;
if db_warm {
set_list(&weak, cached, format!("{n0} files (cached)"));
}
// 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.into_iter().map(|e| e.path).collect(),
format!("Scanning… {n}"),
let Some(w) = weak.upgrade() else { return };
let model = w.get_items();
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
return
};
let entries = app.entries.lock().unwrap();
let mut painted = 0usize;
for r in &results {
// Guard: list may have been reset between submit and delivery.
if !r.ok
|| r.id >= m.row_count()
|| r.id >= entries.len()
|| entries[r.id].path != r.path
{
continue;
}
let mut buffer = SharedPixelBuffer::<Rgb8Pixel>::new(r.width, r.height);
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
let image = Image::from_rgb8(buffer);
painted += 1;
m.set_row_data(
r.id,
Item {
path: SharedString::from(r.path.clone()),
image,
},
);
}
});
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| {
eprintln!("gallery: db apply error: {e}");
(0, 0, 0)
});
let paths = db::list_paths(&mut db).unwrap_or_default();
let n = paths.len();
let ms = t0.elapsed().as_millis();
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
/// M1: cached DB paints instantly on relaunch; a background walk refreshes it
/// (streaming into the list on cold start). M2: the thumbnail pool fills rows
/// as they appear — top rows on paint, the visible range on scroll.
pub fn start_scan(window: &Main) {
let model = ModelRc::new(VecModel::<Item>::default());
window.set_items(model);
let weak = window.as_weak();
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")]
use std::sync::OnceLock;
fn scan_thread(weak: &slint::Weak<Main>) {
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
/// stays reachable from ANY thread after Slint has consumed the original:

View File

@ -1,7 +1,23 @@
use gallery::Main;
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> {
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
let main_window = Main::new()?;
gallery::start_scan(&main_window);
main_window.show()?;

View File

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