diff --git a/Cargo.lock b/Cargo.lock index bee9702..e10f55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3a49142..5c0831f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/HANDOFF.md b/HANDOFF.md index a831b80..d6c475b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/scripts/build.sh b/scripts/build.sh index f151d22..8921314 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 diff --git a/src/db.rs b/src/db.rs index 3c9dc29..3c75f75 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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, 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, 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, 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 = 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()))); } } diff --git a/src/images.rs b/src/images.rs new file mode 100644 index 0000000..292e9df --- /dev/null +++ b/src/images.rs @@ -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, + 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 { + 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, 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, 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 { + 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 { + 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 { + 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, w: u32, h: u32, orientation: u8) -> (Vec, 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, + 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::, Vec>::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, 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)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0d3a19d..d18e9d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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
, paths: Vec, 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 = OnceLock::new(); + +struct AppState { + engine: Engine, + /// Current list (newest first); source for scroll prefetch. UI thread only. + entries: Mutex>, + /// Paths already handed to the pool for the current list. UI thread only. + submitted: Mutex>, + /// 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
, entries: Vec, 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::>() { - m.set_vec(paths.into_iter().map(SharedString::from).collect::>()); + 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 = 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::>() { + 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
, 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::::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) { + 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 = 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
) { + 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::>() 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::::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::::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
) { + 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: diff --git a/src/main.rs b/src/main.rs index b7bfaf6..b07ce5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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()?; diff --git a/src/main.slint b/src/main.slint index 22a38cc..0a43e38 100644 --- a/src/main.slint +++ b/src/main.slint @@ -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 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 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; diff --git a/src/thumb.rs b/src/thumb.rs new file mode 100644 index 0000000..631989b --- /dev/null +++ b/src/thumb.rs @@ -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, + pub width: u32, + pub height: u32, + pub from_cache: bool, + pub elapsed_ms: u128, +} + +pub struct Engine { + tx: mpsc::Sender, + /// Only the UI thread drains; the Mutex just makes Engine Sync so it can + /// live in a process-wide OnceLock. + rx: Mutex>, + gen: Arc, + cache_dir: PathBuf, + in_flight: Arc, + done: Arc, + hits: Arc, + errs: Arc, + 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 { + fs::create_dir_all(&cache_dir).map_err(|e| format!("mkdir {}: {e}", cache_dir.display()))?; + let (job_tx, job_rx) = mpsc::channel::(); + let (res_tx, res_rx) = mpsc::channel::(); + 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::(); + 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 { + 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, + hits: Arc, + errs: Arc, +} + +fn worker_loop( + rx: mpsc::Receiver, + res_tx: mpsc::Sender, + gen: Arc, + in_flight: Arc, + done: Arc, + hits: Arc, + errs: Arc, + 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, + errs: &Arc, +) -> (bool, Option, 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 { + 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::>(); + + // 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::>()); + + 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::>()); + 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 + } +}