gallery/harnesses/gtauri/src/images.rs
Greg Pomerantz 02d1f097ac gtauri: fix deep-scroll stall + instant viewer
Root causes found via per-event tracing (trace.log) + heartbeats:

1. Decode backlog: single worker at ~300ms/photo could not keep up with
   fast long scrolls — phone session ended with a 2400-deep queue
   draining at ~3 cells/s (13+ min to catch up). That was the
   'scroll far down and nothing loads no matter how long I wait' zone.
   - 4 parallel decode workers (~12 photos/s)
   - viewport-aware stale drops: prefetch jobs more than 60 rows behind
     the viewport are dropped, not decoded (fleeing leaves no backlog)
   - Q_CAP bounds the queue; JS 1.5s safety net re-requests
   - queue peak in repro test: 42 (was 2405); all 850 decodable
     emulator images now load (708 before)

2. Stale-anchor bug: sendAnchor was leading-edge throttled with no
   trailing call — if a scroll ended inside the 150ms window the Rust
   anchor stayed stale forever, and the worker then dropped fresh
   URGENT jobs (tap → full read) as 'off-screen'. Fix: trailing
   anchor send; and URGENT jobs are never stale-dropped (only
   prefetch work is droppable).

3. Viewer latency: full-res read ran on Chromium's main thread
   (wry shouldInterceptRequest is synchronous) — 30-50MB flash read
   froze the whole WebView. Now: thumb shown instantly (mem cache,
   ~1ms), full read queued urgent and done off-main; full-ready event
   swaps in the full-res (256MB full-res LRU makes re-opens instant).
   Measured click→full-res: 4ms on emulator.

4. Scan: skip OS junk (._* AppleDouble forks, dotfiles) — the phone's
   DCIM carried 72 of them, rendering as permanent gray cells.

5. Viewer for a corrupt file now shows 'image unavailable' instead of
   'loading…' forever; grid retry timers skip far-off-screen cells.
2026-11-01 09:11:11 -05:00

233 lines
8.8 KiB
Rust

//! 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 (not the hot
//! path). Pipeline output is RGB8; the Tauri layer re-encodes it as a small
//! JPEG for the on-disk cache — Chromium then decodes THAT (its async
//! decode threads + image cache + eviction do the rest).
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};
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.
///
/// JPEG goes through turbojpeg (fast DCT-scaled decode). When that fails —
/// the classic case is a WEBP/GIF file saved with a `.jpg` name, which
/// turbojpeg rejects ("Not a JPEG file: starts with 0x52 0x49" = RIFF) —
/// fall back to the format-guessing `image` crate so mislabeled files still
/// render. (Files that fail BOTH are marked BROKEN upstream and never
/// retried.)
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).or_else(|e| {
generic_decode(path, target).map_err(|ge| format!("{e}; fallback: {ge}"))
})
}
_ => generic_decode(path, target),
}
}
/// Format-guessing decode (webp, gif, png, …) for non-turbojpeg files.
pub fn generic_decode(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| format!("format: {e}"))?
.decode()
.map_err(|e| format!("decode {}: {e}", path.display()))?;
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))
}
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 }
}