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.
233 lines
8.8 KiB
Rust
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 }
|
|
}
|