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:
parent
c72b00f7ec
commit
572409b702
134
Cargo.lock
generated
134
Cargo.lock
generated
|
|
@ -258,6 +258,12 @@ version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arboard"
|
name = "arboard"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|
@ -558,6 +564,15 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -771,6 +786,15 @@ dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
|
|
@ -826,6 +850,12 @@ dependencies = [
|
||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
@ -881,6 +911,15 @@ version = "3.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.3"
|
version = "1.5.3"
|
||||||
|
|
@ -945,6 +984,15 @@ version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctor"
|
name = "ctor"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -989,6 +1037,17 @@ dependencies = [
|
||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -1495,11 +1554,15 @@ name = "gallery"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
|
"image",
|
||||||
|
"kamadak-exif",
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"sha1",
|
||||||
"slint",
|
"slint",
|
||||||
"slint-build",
|
"slint-build",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"turbojpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1524,6 +1587,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gcd"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -1780,6 +1849,15 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144"
|
checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-backend-android-activity"
|
name = "i-slint-backend-android-activity"
|
||||||
version = "1.18.1"
|
version = "1.18.1"
|
||||||
|
|
@ -2506,6 +2584,15 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kamadak-exif"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
|
||||||
|
dependencies = [
|
||||||
|
"mutate_once",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -2807,6 +2894,12 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mutate_once"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "30.0.1"
|
version = "30.0.1"
|
||||||
|
|
@ -4067,6 +4160,17 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -4747,6 +4851,30 @@ version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "turbojpeg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81313fcd64aaa30ddfd59eaa122968136b2dd9cd41d9619b126cf71e67a01702"
|
||||||
|
dependencies = [
|
||||||
|
"gcd",
|
||||||
|
"libc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"turbojpeg-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "turbojpeg-sys"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49b5a974bc59382d35927e3a07eef2ba9a24fe7227cefe52ec44b508e1b90f86"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"cmake",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typed-index-collections"
|
name = "typed-index-collections"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
|
|
@ -4757,6 +4885,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "udev"
|
name = "udev"
|
||||||
version = "0.9.3"
|
version = "0.9.3"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ slint = { version = "1.18.1", features = ["backend-android-activity-06"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
android_logger = { version = "0.14", target-family = "android" }
|
android_logger = { version = "0.14", target-family = "android" }
|
||||||
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
||||||
|
sha1 = "0.11.0"
|
||||||
|
turbojpeg = { version = "1.5.1", features = ["cmake"] }
|
||||||
|
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||||||
|
kamadak-exif = "0.6.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
slint-build = "1.18.1"
|
slint-build = "1.18.1"
|
||||||
|
|
|
||||||
89
HANDOFF.md
89
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
|
from the framework evaluation that preceded it, so no learning has to be
|
||||||
rediscovered.
|
rediscovered.
|
||||||
|
|
||||||
**Status: M0 done** — this scaffold builds and runs on the emulator
|
**Status: M2 done (2026-10-27)** — M0 scaffold, M1 metadata scan + sqlite +
|
||||||
(`./scripts/build.sh debug`), including the permission-aware Android init and
|
instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink decode, worker
|
||||||
the lifecycle-event hook.
|
pool, disk cache, cancellation) all verified: unit tests, 5k synthetic engine
|
||||||
|
test, and visually on a real Pixel 9 Pro (8,577 photos, thumbnails in every
|
||||||
|
row). Next: M3 grid UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -117,18 +119,17 @@ thumbnail engine, and it is framework-agnostic:
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Planned module layout:
|
Module layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
lib.rs # android_main, ANDROID_APP stash, platform init [exists]
|
lib.rs # android_main + platform init + scan/UI glue (M1/M2)
|
||||||
main.rs # desktop entry [exists]
|
main.rs # desktop entry [exists]
|
||||||
main.slint # screens (split into ui/ components at M3) [exists]
|
main.slint # screens (split into ui/ components at M3) [exists]
|
||||||
db.rs # sqlite schema + scan (M1)
|
db.rs # sqlite schema + incremental scan apply [done M1]
|
||||||
scan.rs # directory walk, image detection, metadata read (M1)
|
scan.rs # directory walk, image detection, walk_stream [done M1]
|
||||||
thumb.rs # pool, decode@size, disk cache, cancellation (M2)
|
thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
|
||||||
images.rs # decode dispatch (zune-jpeg / imagepipe / png) (M2)
|
images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
|
||||||
ui.rs # Rust↔Slint glue: Model provider, thumb→Image (M3)
|
|
||||||
viewer.rs # full-size load + transform state (M4)
|
viewer.rs # full-size load + transform state (M4)
|
||||||
perm.rs # storage permission check/request (M5)
|
perm.rs # storage permission check/request (M5)
|
||||||
```
|
```
|
||||||
|
|
@ -138,11 +139,12 @@ Crate choices (all verified current on crates.io, 2026-10):
|
||||||
| crate | version | why |
|
| crate | version | why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
|
| `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
|
||||||
| `rusqlite` | latest, `bundled` feature | SQLite without system deps; cross-compiles to Android |
|
| `rusqlite` | 0.41, `bundled` feature | SQLite without system deps; cross-compiles to Android |
|
||||||
| `zune-jpeg` | 0.5.x (upd 2026-10) | pure-Rust JPEG, **turbo mode** = decode+downscale one pass. Preferred over `turbojpeg` for Android: no C dep to cross-build |
|
| `turbojpeg` | 1.5.1, `cmake` feature | libjpeg-turbo DCT-level shrink decode (1/2,1/4,1/8) — 48 MP at 1/8 peaks ~2.3 MB vs 137 MB full decode. Vendors + cross-builds libjpeg-turbo via CMake/NDK (verified). Chosen over `libvips` (same DCT shrink mechanism, +6–10 MB of .so files, LGPL, 4.3k downloads) and over `zune-jpeg` (no DCT shrink = full-size decode for 12 MP photos). |
|
||||||
| `image` | 0.25.x | PNG + header/metadata (`ImageReader::into_dimensions`, EXIF orientation) |
|
| `image` | 0.25, `default-features=false, png` | PNG thumbs + `imageops::resize` final fit |
|
||||||
| `imagepipe` | 0.5.x (upd 2026-08) | HEIC/HEIF via Apple ImagePipe — **open question: Android NDK support** (see §10) |
|
| `kamadak-exif` | 0.6 | EXIF orientation tag (all 8 values applied as pixel maps); `experimental::Writer` used only in tests |
|
||||||
| `slint::Image::from_rgba8_premultiplied` | — | how decoded thumbs enter the UI |
|
| `sha1` | 0.11 | cache keys |
|
||||||
|
| `imagepipe` | — (deferred) | HEIC: the real device has **0 HEIC** files (9109 JPEG, 27 PNG), so HEIC support is deferred; `image` can gain `heic` or libvips can replace the JPEG path later — the engine API is decoder-agnostic |
|
||||||
|
|
||||||
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side.
|
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side.
|
||||||
Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in
|
Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in
|
||||||
|
|
@ -302,10 +304,42 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
|
||||||
survives `adb install -r`.
|
survives `adb install -r`.
|
||||||
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
|
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
|
||||||
or the app can't read them.
|
or the app can't read them.
|
||||||
- **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache;
|
- **M2 — thumbnail engine (DONE, 2026-10-27)**: `images.rs` (turbojpeg
|
||||||
cancellation; pool; synthetic 5k test set; timing logs.
|
DCT-level shrink decode via 1/2/1/4/1/8 `ScalingFactor`, all 8 EXIF
|
||||||
*Accept: first grid fill < 2 s; re-launch fills instantly from disk cache;
|
orientations as uniform `(x,y)→(dx,dy)` pixel maps, `image::imageops::resize`
|
||||||
fast scroll causes no backlog (watch pending-queue length in logs).*
|
final fit, re-encode to disk cache at q85; PNG via `image`) + `thumb.rs`
|
||||||
|
(dispatcher thread round-robining to N=cores/2 workers clamped 2..8,
|
||||||
|
unbounded job queue, disk cache `thumbs/{sha1(path|mtime|size|target)}.jpg`
|
||||||
|
— target in the key so list-64px and grid-500px coexist; atomic
|
||||||
|
`.tmp`→rename; generation counter for cancellation; per-worker sqlite
|
||||||
|
connection writes `set_thumb` w/h/orientation/thumb_path) + `lib.rs`
|
||||||
|
(process-wide `AppState` in `OnceLock`, 10 Hz drain timer — `Timer` is
|
||||||
|
`!Send`, leaked with `std::mem::forget`, scroll-prefetch with 50-row
|
||||||
|
margin, `do_prefetch` flag so streaming scan batches don't re-prefetch) +
|
||||||
|
`main.slint` (rows carry `image` now).
|
||||||
|
*Accept, verified:* 12 unit tests + 5k synthetic engine test (cold fill,
|
||||||
|
100% cache warm refill, generation cancel). Release aarch64 APK 9.8 MB.
|
||||||
|
**Real device (Pixel 9 Pro, 8577 photos):** cold scan ~5 s with list
|
||||||
|
streaming; warm relaunch paints the cached list instantly; thumbnails
|
||||||
|
render in every visible row (screenshot-verified); relaunch cache hits
|
||||||
|
~100% (255–258 of first 256 jobs); 2 WebP files misnamed `.jpg` fail
|
||||||
|
gracefully (logged, row stays empty). Worker log cadence: every 256 jobs.
|
||||||
|
*Known transient (unreproducible): the very first aarch64-release install
|
||||||
|
rendered a pure-black screen (no drain-timer "painted" logs) until the
|
||||||
|
next launch; it did **not** reproduce on the emulator (screen-off launch +
|
||||||
|
wake) nor across repeated device launches. Suspected screen-lock / display
|
||||||
|
state race on first GL frame. If it recurs: force a window refresh on
|
||||||
|
Resume (see §10.7).*
|
||||||
|
*Build gotchas hit (also in build.sh comments):*
|
||||||
|
- `ANDROID_PLATFORM=android-26` (NDK CMake toolchain) **conflicts** with
|
||||||
|
the `android-build` crate (Slint's Java compile) unless `ANDROID_JAR`
|
||||||
|
is set explicitly to `platforms/android-35/android.jar`.
|
||||||
|
- x86_64 host builds of turbojpeg-sys need `nasm` (`~/.local/bin/nasm`;
|
||||||
|
aarch64 does not).
|
||||||
|
- `ADB_SERIAL=…` env var picks the device in build.sh (phone + emulator
|
||||||
|
are often both attached).
|
||||||
|
- build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`,
|
||||||
|
`aarch64`; second arg `no-install` to skip install.
|
||||||
- **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer,
|
- **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer,
|
||||||
pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery →
|
pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery →
|
||||||
cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped-
|
cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped-
|
||||||
|
|
@ -324,12 +358,10 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
|
||||||
|
|
||||||
## 10. Open questions & risks
|
## 10. Open questions & risks
|
||||||
|
|
||||||
1. **HEIC on Android**: `imagepipe` is Apple's ImagePipe — its Rust crate
|
1. **HEIC (RESOLVED for now)**: the real device has 0 HEIC files (9109
|
||||||
may only ship Apple prebuilts. **First task of M2: verify an NDK build**
|
JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
|
||||||
(`cargo apk2 build` with imagepipe). Fallbacks: `libheif-rs`/libheif
|
`images.rs` dispatch (libheif-rs / `image` heic feature / libvips);
|
||||||
(C, cross-buildable), or skip HEIC initially and document JPEG/PNG only
|
the engine API is decoder-agnostic.
|
||||||
(the emulator test set is JPEG anyway; real device photos from this
|
|
||||||
ecosystem may already be JPEG — check passgo's existing files).
|
|
||||||
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
|
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
|
||||||
backend at eval time): could fire on trackpad/touch scroll. Test scrolling
|
backend at eval time): could fire on trackpad/touch scroll. Test scrolling
|
||||||
on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19
|
on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19
|
||||||
|
|
@ -344,6 +376,13 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
|
||||||
@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade.
|
@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade.
|
||||||
6. **rusqlite `bundled`** compiles SQLite from source in every target
|
6. **rusqlite `bundled`** compiles SQLite from source in every target
|
||||||
(adds ~30–60 s to clean builds) — acceptable; revisit if it hurts.
|
(adds ~30–60 s to clean builds) — acceptable; revisit if it hurts.
|
||||||
|
7. **Transient black screen (M2 first launch, unreproducible)**: one
|
||||||
|
aarch64-release launch rendered pure black with a dead drain timer until
|
||||||
|
relaunch; not reproduced on emulator or device since. Candidate fix if it
|
||||||
|
recurs: in the `init_with_event_listener` Resume handler, call
|
||||||
|
`window.request_redraw()` / check `window.size()` (a 0×0 or stale surface
|
||||||
|
after a screen-off start would match the symptoms). Don't add it
|
||||||
|
speculatively.
|
||||||
|
|
||||||
## 11. Pointers
|
## 11. Pointers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,33 @@ cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
export ANDROID_HOME=$HOME/android-sdk
|
export ANDROID_HOME=$HOME/android-sdk
|
||||||
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
|
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
|
||||||
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
|
# ~/.local/bin: nasm (host build of libjpeg-turbo SIMD; aarch64 needs no nasm).
|
||||||
|
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||||
|
|
||||||
|
# turbojpeg (M2): build the vendored libjpeg-turbo with CMake, statically linked
|
||||||
|
# into the app (no extra .so in the APK). The NDK CMake toolchain + ANDROID_*
|
||||||
|
# vars are picked up by the `cmake` crate; verified 2026-10 (HANDOFF §10).
|
||||||
|
if [ -n "$ANDROID_NDK_ROOT" ]; then
|
||||||
|
export CMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake
|
||||||
|
export ANDROID_PLATFORM=android-26
|
||||||
|
export ANDROID_STL=c++_static
|
||||||
|
export TURBOJPEG_STATIC=1
|
||||||
|
fi
|
||||||
|
# SDK platform used to compile the Slint Java helper (target API, matches
|
||||||
|
# target_sdk_version in Cargo.toml). Must be an installed platform dir.
|
||||||
|
SDK_PLATFORM=android-35
|
||||||
|
|
||||||
|
# Slint's android-activity build reads ANDROID_JAR first; setting it explicitly
|
||||||
|
# lets us ALSO export ANDROID_PLATFORM=android-26 below (for the NDK CMake
|
||||||
|
# toolchain's native API level) without the two tools fighting over that
|
||||||
|
# variable (`android-build` would otherwise look up platforms/android-26).
|
||||||
|
export ANDROID_JAR=$ANDROID_HOME/platforms/$SDK_PLATFORM/android.jar
|
||||||
|
|
||||||
# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite).
|
# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite).
|
||||||
# The `cc` crate picks these up per target triple (min sdk 26).
|
# The `cc` crate picks these up per target triple (min sdk 26).
|
||||||
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
|
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
|
||||||
|
# CMake target ABI (x86_64 default; overridden below for aarch64).
|
||||||
|
export ANDROID_ABI=x86_64
|
||||||
export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang
|
export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang
|
||||||
export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
|
export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
|
||||||
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
|
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
|
||||||
|
|
@ -33,16 +56,22 @@ arg2=${2:-install}
|
||||||
profile=--release
|
profile=--release
|
||||||
case $arg1 in
|
case $arg1 in
|
||||||
debug) target=x86_64-linux-android; profile="" ;;
|
debug) target=x86_64-linux-android; profile="" ;;
|
||||||
|
debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;;
|
||||||
x86_64) target=x86_64-linux-android ;;
|
x86_64) target=x86_64-linux-android ;;
|
||||||
aarch64) target=aarch64-linux-android ;;
|
aarch64) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a ;;
|
||||||
*) echo "unknown arch: $arg1" >&2; exit 1 ;;
|
*) echo "unknown arch: $arg1" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
profile_dir=release; [ -z "$profile" ] && profile_dir=debug
|
||||||
|
|
||||||
|
# Optional: ADB_SERIAL picks the device when several are attached (see AGENTS.md).
|
||||||
|
adb=(); [ -n "$ADB_SERIAL" ] && adb=(adb -s "$ADB_SERIAL")
|
||||||
|
|
||||||
cargo apk2 build $profile --target "$target" --lib
|
cargo apk2 build $profile --target "$target" --lib
|
||||||
apk=$(ls target/*/apk/gallery.apk | head -1)
|
apk=target/$profile_dir/apk/gallery.apk
|
||||||
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
|
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
|
||||||
|
|
||||||
if [ "$arg2" = install ] && command -v adb >/dev/null; then
|
if [ "$arg2" = install ] && command -v adb >/dev/null; then
|
||||||
adb install -r "$apk"
|
"${adb[@]}" install -r "$apk"
|
||||||
adb shell am start -n wow.gmp.gallery/android.app.NativeActivity || true
|
"${adb[@]}" shell am start -n wow.gmp.gallery/android.app.NativeActivity || true
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
59
src/db.rs
59
src/db.rs
|
|
@ -39,6 +39,9 @@ fn android_data_dir() -> PathBuf {
|
||||||
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
|
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
|
||||||
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
|
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
|
||||||
pub fn db_path() -> PathBuf {
|
pub fn db_path() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("GALLERY_DB_PATH") {
|
||||||
|
return PathBuf::from(p); // test hook
|
||||||
|
}
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let dir = android_data_dir();
|
let dir = android_data_dir();
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|
@ -137,10 +140,12 @@ pub fn apply(
|
||||||
Ok((inserted, updated, deleted))
|
Ok((inserted, updated, deleted))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All known paths, newest first.
|
/// All known entries, newest first.
|
||||||
pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error> {
|
pub fn list_entries(conn: &mut Connection) -> Result<Vec<Entry>, rusqlite::Error> {
|
||||||
let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?;
|
let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
|
||||||
let rows = st.query_map([], |r| r.get::<_, String>(0))?;
|
let rows = st.query_map([], |r| {
|
||||||
|
Ok(Entry { path: r.get(0)?, mtime: r.get(1)?, size: r.get(2)? })
|
||||||
|
})?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
out.push(row?);
|
out.push(row?);
|
||||||
|
|
@ -148,6 +153,23 @@ pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error>
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the derived thumbnail columns once the pool has finished a job.
|
||||||
|
pub fn set_thumb(
|
||||||
|
conn: &mut Connection,
|
||||||
|
path: &str,
|
||||||
|
width: i64,
|
||||||
|
height: i64,
|
||||||
|
orientation: i64,
|
||||||
|
thumb_path: &str,
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE items SET width = ?2, height = ?3, orientation = ?4, thumb_path = ?5 \
|
||||||
|
WHERE path = ?1",
|
||||||
|
params![path, width, height, orientation, thumb_path],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -177,10 +199,31 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((i, u, d), (1, 1, 1));
|
assert_eq!((i, u, d), (1, 1, 1));
|
||||||
|
|
||||||
let paths = list_paths(&mut conn).unwrap();
|
let list = list_entries(&mut conn).unwrap();
|
||||||
|
let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect();
|
||||||
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
|
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_thumb_updates_row_and_invalidation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut conn = open(&dir.path().join("t.db")).unwrap();
|
||||||
|
apply(&mut conn, &[entry("/a/1.jpg", 1, 10)]).unwrap();
|
||||||
|
set_thumb(&mut conn, "/a/1.jpg", 8000, 6016, 6, "/cache/abc.jpg").unwrap();
|
||||||
|
let thumb: String = conn
|
||||||
|
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(thumb, "/cache/abc.jpg");
|
||||||
|
|
||||||
|
// mtime change → apply() invalidates the derived columns.
|
||||||
|
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 2, 12)]).unwrap();
|
||||||
|
assert_eq!((i, u, d), (0, 1, 0));
|
||||||
|
let thumb: Option<String> = conn
|
||||||
|
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(thumb, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_then_apply_roundtrip() {
|
fn scan_then_apply_roundtrip() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -195,8 +238,8 @@ mod tests {
|
||||||
let (i, u, d) = apply(&mut conn, &entries).unwrap();
|
let (i, u, d) = apply(&mut conn, &entries).unwrap();
|
||||||
assert_eq!((i, u, d), (2, 0, 0));
|
assert_eq!((i, u, d), (2, 0, 0));
|
||||||
|
|
||||||
let paths = list_paths(&mut conn).unwrap();
|
let list = list_entries(&mut conn).unwrap();
|
||||||
assert_eq!(paths.len(), 2);
|
assert_eq!(list.len(), 2);
|
||||||
assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap())));
|
assert!(list.iter().all(|e| e.path.contains(dir.path().to_str().unwrap())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
331
src/images.rs
Normal file
331
src/images.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/lib.rs
250
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!();
|
slint::include_modules!();
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod images;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
pub mod thumb;
|
||||||
|
|
||||||
use slint::{ComponentHandle, Model, SharedString, VecModel};
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
/// Fill the window's list model from `paths` and set the status line.
|
use slint::{
|
||||||
fn set_list(weak: &slint::Weak<Main>, paths: Vec<String>, status: String) {
|
ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, SharedPixelBuffer, SharedString, Timer,
|
||||||
|
TimerMode, VecModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::scan::Entry;
|
||||||
|
use crate::thumb::{Engine, Job, LIST_TARGET};
|
||||||
|
|
||||||
|
/// Rows to submit to the pool whenever the list is (re)painted: the top of
|
||||||
|
/// the "newest" order, i.e. what the user actually sees first.
|
||||||
|
const PREFETCH_FIRST: usize = 200;
|
||||||
|
/// Rows on each side of the visible range that scroll-prefetch keeps ready.
|
||||||
|
const SCROLL_MARGIN: usize = 50;
|
||||||
|
/// Row height must match main.slint.
|
||||||
|
const ROW_HEIGHT: f32 = 36.0;
|
||||||
|
const DRAIN_INTERVAL_MS: u64 = 100;
|
||||||
|
|
||||||
|
static STATE: OnceLock<AppState> = OnceLock::new();
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
engine: Engine,
|
||||||
|
/// Current list (newest first); source for scroll prefetch. UI thread only.
|
||||||
|
entries: Mutex<Vec<Entry>>,
|
||||||
|
/// Paths already handed to the pool for the current list. UI thread only.
|
||||||
|
submitted: Mutex<HashSet<String>>,
|
||||||
|
/// Total thumbnails painted; drives the acceptance log cadence.
|
||||||
|
painted: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the pool + cache dir once per process.
|
||||||
|
fn state() -> &'static AppState {
|
||||||
|
STATE.get_or_init(|| {
|
||||||
|
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
|
||||||
|
let cache_dir = base.join("thumbs");
|
||||||
|
let workers =
|
||||||
|
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
|
||||||
|
let engine = Engine::new(cache_dir, workers, LIST_TARGET).expect("thumb engine");
|
||||||
|
log::info!(
|
||||||
|
"gallery: thumb engine up: {} workers, target {} px, cache {}",
|
||||||
|
workers,
|
||||||
|
LIST_TARGET,
|
||||||
|
engine.cache_dir().display()
|
||||||
|
);
|
||||||
|
AppState {
|
||||||
|
engine,
|
||||||
|
entries: Mutex::new(Vec::new()),
|
||||||
|
submitted: Mutex::new(HashSet::new()),
|
||||||
|
painted: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the list model (UI thread) + set the status line. A model
|
||||||
|
/// replacement invalidates in-flight work for the previous list: bump the
|
||||||
|
/// generation and clear the submitted set so repaints re-prefetch.
|
||||||
|
/// `do_prefetch` is false for cold-scan streaming batches (nothing to show
|
||||||
|
/// yet — re-submitting the top rows every batch would waste decode work).
|
||||||
|
fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String, do_prefetch: bool) {
|
||||||
let weak = weak.clone();
|
let weak = weak.clone();
|
||||||
let _ = slint::invoke_from_event_loop(move || {
|
let _ = slint::invoke_from_event_loop(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() {
|
let Some(app) = STATE.get() else { return };
|
||||||
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>());
|
app.engine.bump_generation();
|
||||||
|
app.submitted.lock().unwrap().clear();
|
||||||
|
*app.entries.lock().unwrap() = entries.clone();
|
||||||
|
let items: Vec<Item> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| Item {
|
||||||
|
path: SharedString::from(e.path.clone()),
|
||||||
|
image: Image::default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
|
||||||
|
m.set_vec(items);
|
||||||
}
|
}
|
||||||
w.set_status(status.into());
|
w.set_status(status.into());
|
||||||
|
if do_prefetch {
|
||||||
|
// Top of the list starts filling now (cold: decodes; warm: cache hits).
|
||||||
|
prefetch(app, 0..PREFETCH_FIRST);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,31 +112,142 @@ fn set_status(weak: &slint::Weak<Main>, status: String) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// M1: metadata from sqlite paints the list immediately (first frame < 100 ms
|
/// Submit not-yet-submitted entries in `range` (row indexes) to the pool.
|
||||||
/// per HANDOFF §8); a background walk then refreshes the DB and the list.
|
/// UI thread only.
|
||||||
/// UI updates are posted to the event loop — the scan thread never touches the
|
fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
|
||||||
/// window directly. Called from both entry points after `Main::new()`.
|
if range.is_empty() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
pub fn start_scan(window: &Main) {
|
||||||
let model = slint::ModelRc::new(VecModel::<SharedString>::default());
|
let model = ModelRc::new(VecModel::<Item>::default());
|
||||||
window.set_items(model);
|
window.set_items(model);
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
let app = state();
|
||||||
|
|
||||||
|
window.on_scroll_changed(move |y: f32, h: f32| on_scroll(app, y, h));
|
||||||
|
start_drain_timer(&weak);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
scan_thread(&weak);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_thread(weak: &slint::Weak<Main>) {
|
||||||
let mut db = match db::open(&db::db_path()) {
|
let mut db = match db::open(&db::db_path()) {
|
||||||
Ok(db) => db,
|
Ok(db) => db,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("gallery: db error: {e}");
|
log::error!("gallery: db error: {e}");
|
||||||
set_list(&weak, Vec::new(), format!("db error: {e}"));
|
set_list(weak, Vec::new(), format!("db error: {e}"), false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Instant first paint from the cached DB (relaunch path).
|
// 1. Instant first paint from the cached DB (relaunch path).
|
||||||
let cached = db::list_paths(&mut db).unwrap_or_default();
|
let cached = db::list_entries(&mut db).unwrap_or_default();
|
||||||
let n0 = cached.len();
|
let db_warm = !cached.is_empty();
|
||||||
let db_warm = n0 > 0;
|
|
||||||
if db_warm {
|
if db_warm {
|
||||||
set_list(&weak, cached, format!("{n0} files (cached)"));
|
let n0 = cached.len();
|
||||||
|
set_list(weak, cached, format!("{n0} files (cached)"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Background rescan: detect new/changed/removed files.
|
// 2. Background rescan: detect new/changed/removed files.
|
||||||
|
|
@ -63,34 +259,28 @@ pub fn start_scan(window: &Main) {
|
||||||
let entries = scan::walk_stream(&roots, &|batch| {
|
let entries = scan::walk_stream(&roots, &|batch| {
|
||||||
let n = batch.len();
|
let n = batch.len();
|
||||||
if db_warm {
|
if db_warm {
|
||||||
set_status(&weak, format!("Updating… {n}"));
|
set_status(weak, format!("Updating… {n}"));
|
||||||
} else {
|
} else {
|
||||||
let mut sorted = batch.to_vec();
|
let mut sorted = batch.to_vec();
|
||||||
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
|
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
|
||||||
set_list(
|
set_list(weak, sorted, format!("Scanning… {n}"), false);
|
||||||
&weak,
|
|
||||||
sorted.into_iter().map(|e| e.path).collect(),
|
|
||||||
format!("Scanning… {n}"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
|
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
|
||||||
eprintln!("gallery: db apply error: {e}");
|
log::error!("gallery: db apply error: {e}");
|
||||||
(0, 0, 0)
|
(0, 0, 0)
|
||||||
});
|
});
|
||||||
let paths = db::list_paths(&mut db).unwrap_or_default();
|
let entries = db::list_entries(&mut db).unwrap_or_default();
|
||||||
let n = paths.len();
|
let n = entries.len();
|
||||||
let ms = t0.elapsed().as_millis();
|
let ms = t0.elapsed().as_millis();
|
||||||
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
|
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
|
||||||
|
|
||||||
set_list(&weak, paths, format!("{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);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
/// A clone of `AndroidApp` kept for the app's whole lifetime so the Java side
|
/// A clone of `AndroidApp` kept for the app's whole lifetime so the Java side
|
||||||
/// stays reachable from ANY thread after Slint has consumed the original:
|
/// stays reachable from ANY thread after Slint has consumed the original:
|
||||||
/// `run_on_java_main_thread`, `vm_as_ptr()` (JVM), `activity_as_ptr()` (Activity).
|
/// `run_on_java_main_thread`, `vm_as_ptr()` (JVM), `activity_as_ptr()` (Activity).
|
||||||
|
|
|
||||||
16
src/main.rs
16
src/main.rs
|
|
@ -1,7 +1,23 @@
|
||||||
use gallery::Main;
|
use gallery::Main;
|
||||||
use slint::ComponentHandle;
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
|
/// Minimal eprintln logger for desktop dev (Android uses android_logger).
|
||||||
|
struct Stdout;
|
||||||
|
|
||||||
|
impl log::Log for Stdout {
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
_metadata.level() <= log::Level::Info
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
if self.enabled(record.metadata()) {
|
||||||
|
eprintln!("[{}] {}", record.metadata().level(), record.args());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), slint::PlatformError> {
|
fn main() -> Result<(), slint::PlatformError> {
|
||||||
|
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
|
||||||
let main_window = Main::new()?;
|
let main_window = Main::new()?;
|
||||||
gallery::start_scan(&main_window);
|
gallery::start_scan(&main_window);
|
||||||
main_window.show()?;
|
main_window.show()?;
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,70 @@
|
||||||
// Gallery — M1: metadata scan + placeholder list.
|
// Gallery — M2: metadata list with streaming thumbnails.
|
||||||
// The real grid (image cells) arrives with M3 per HANDOFF.md.
|
// Rows are 36px: 28px thumbnail (image-fit: cover) + path text.
|
||||||
|
// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll); the grid (M3)
|
||||||
|
// replaces the row body with square image cells.
|
||||||
import { ListView, VerticalBox } from "std-widgets.slint";
|
import { ListView, VerticalBox } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export struct Item {
|
||||||
|
path: string,
|
||||||
|
image: image,
|
||||||
|
}
|
||||||
|
|
||||||
export component Main inherits Window {
|
export component Main inherits Window {
|
||||||
title: "Gallery";
|
title: "Gallery";
|
||||||
|
|
||||||
/// Scan/DB status line ("Scanning… 1234", "5123 files (42+ 3~ 1–) in 380 ms").
|
/// Fired by the ListView on every scroll step: (content-y, list height).
|
||||||
in property <string> status: "Scanning…";
|
/// content-y is negative while scrolled down. (Rust setter: on_scroll_changed)
|
||||||
/// Scanned file paths, newest first (M1: shown as text rows).
|
callback scroll-changed(y: length, height: length);
|
||||||
in property <[string]> items;
|
|
||||||
|
/// Scan/DB status line.
|
||||||
|
in property <string> status: "Starting…";
|
||||||
|
/// Rows, newest first. `image` starts empty and fills as the thumbnail
|
||||||
|
/// pool delivers results.
|
||||||
|
in property <[Item]> items;
|
||||||
|
|
||||||
VerticalBox {
|
VerticalBox {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
spacing: 12px;
|
spacing: 8px;
|
||||||
padding: 24px;
|
padding: 12px;
|
||||||
// Respect status-bar / nav-bar cutouts (built into the Slint window
|
// Respect status-bar / nav-bar cutouts (built into the Slint window
|
||||||
// on Android via safe-area-insets).
|
// on Android via safe-area-insets).
|
||||||
padding-top: 24px + root.safe-area-insets.top;
|
padding-top: 12px + root.safe-area-insets.top;
|
||||||
padding-bottom: 24px + root.safe-area-insets.bottom;
|
padding-bottom: 12px + root.safe-area-insets.bottom;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Gallery";
|
text: root.status;
|
||||||
font-size: 24px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
color: #8ab4f8;
|
||||||
|
overflow: elide;
|
||||||
}
|
}
|
||||||
Text { text: "\{status}"; font-size: 14px; }
|
|
||||||
|
|
||||||
// M1: paths as text. M3 replaces the row body with square image cells.
|
|
||||||
ListView {
|
ListView {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
for path[index] in root.items: Rectangle {
|
// Scroll-driven prefetch: keep ~SCROLL_MARGIN rows on each side
|
||||||
height: 28px;
|
// of the visible range submitted to the pool (see lib.rs).
|
||||||
|
scrolled => {
|
||||||
|
root.scroll-changed(self.content-y, self.visible-height);
|
||||||
|
}
|
||||||
|
for item[index] in root.items: Rectangle {
|
||||||
|
height: 36px;
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424;
|
background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
Image {
|
||||||
|
x: 4px;
|
||||||
|
y: 4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
source: item.image;
|
||||||
|
image-fit: cover;
|
||||||
|
}
|
||||||
Text {
|
Text {
|
||||||
x: 8px;
|
x: 38px;
|
||||||
text: path;
|
y: 0;
|
||||||
|
width: parent.width - 46px;
|
||||||
|
height: 100%;
|
||||||
|
text: item.path;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
|
|
|
||||||
458
src/thumb.rs
Normal file
458
src/thumb.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user