perf: bound decoded-artwork memory, decode covers at display size

- DECODED_PIXEL_CACHE is now byte-budgeted (48 MB, 24 MB on <8 GB RAM
  hosts) instead of a 256-entry cap that could silently hold ~370 MB of
  1000px now-playing decodes.
- Route the full-resolution Image::load_from_path callsites (offline
  manager/favorites, local library ephemeral albums, MyQBZ, playlist
  covers) through the existing decode-to-display-size pipeline.
- Cap Home playlist cards at 40 (was 100).

cc
This commit is contained in:
vicrodh committed 2026-07-01 18:05:14 -06:00
1 parent d7716d7c67
commit f37b6b6ab5
9 files changed
+136 -54

No files matched your search

+80 -10
View File
@@ -568,21 +568,49 @@ pub type DecodedPixels = (Arc<Vec<u8>>, u32, u32);
/// Decoded-pixel LRU. Repeat decodes of the same `(url, size)` — exactly what
/// the coverflow / queue refresh hammered every click — become a HashMap hit +
/// a cheap pixel upload instead of a full `image::load_from_memory().thumbnail()`.
/// Capped to keep a long session from leaking; insertion order approximates LRU
/// Byte-budgeted (large now-playing decodes run ~1.44MB each as RGBA, so an
/// entry cap alone let a long shuffle session grow unbounded); the entry cap
/// stays as a backstop for many tiny entries. Insertion order approximates LRU
/// (re-insert on hit moves the entry to the back).
const DECODED_CACHE_CAP: usize = 256;
/// Byte budget for the decoded-pixel cache: 48MB, lowered to 24MB on
/// small-RAM machines (< 8GB `MemTotal` per `/proc/meminfo`, read once;
/// non-Linux / unreadable falls back to the default).
static DECODED_CACHE_BUDGET: LazyLock<usize> = LazyLock::new(|| {
const DEFAULT: usize = 48 * 1024 * 1024;
const SMALL: usize = 24 * 1024 * 1024;
let small_ram = std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1)?.parse::<u64>().ok())
})
.map(|kb| kb < 8 * 1024 * 1024)
.unwrap_or(false);
if small_ram {
SMALL
} else {
DEFAULT
}
});
struct DecodedCache {
/// `(url, size)` -> decoded pixels. Insertion order = eviction order.
map: HashMap<(String, u32), DecodedPixels>,
/// Keys in insertion order; the front is the eviction candidate.
order: Vec<(String, u32)>,
/// Total pixel bytes held (`w*h*4` per entry), checked against
/// `DECODED_CACHE_BUDGET` on insert.
bytes: usize,
}
static DECODED_PIXEL_CACHE: LazyLock<Mutex<DecodedCache>> = LazyLock::new(|| {
Mutex::new(DecodedCache {
map: HashMap::new(),
order: Vec::new(),
bytes: 0,
})
});
@@ -603,22 +631,37 @@ pub fn decoded_pixels(url: &str, size: u32) -> Option<DecodedPixels> {
hit
}
/// Store decoded pixels for `(url, size)`, evicting the oldest entry past the cap.
/// Store decoded pixels for `(url, size)`, evicting LRU entries until both
/// the byte budget and the entry-count backstop hold.
fn store_decoded(url: &str, size: u32, pixels: &DecodedPixels) {
let Ok(mut cache) = DECODED_PIXEL_CACHE.lock() else {
return;
};
let key = (url.to_string(), size);
if cache.map.insert(key.clone(), pixels.clone()).is_none() {
cache.order.push(key);
} else if let Some(pos) = cache.order.iter().position(|k| k == &key) {
// Refresh recency on an overwrite.
cache.order.remove(pos);
cache.order.push(key);
let entry_bytes = pixels.0.len();
match cache.map.insert(key.clone(), pixels.clone()) {
None => {
cache.order.push(key);
cache.bytes += entry_bytes;
}
Some(old) => {
// Refresh recency + swap the byte accounting on an overwrite.
cache.bytes = cache.bytes.saturating_sub(old.0.len()) + entry_bytes;
if let Some(pos) = cache.order.iter().position(|k| k == &key) {
cache.order.remove(pos);
cache.order.push(key);
}
}
}
while cache.order.len() > DECODED_CACHE_CAP {
// Never evict the just-inserted entry (it sits at the back; the len > 1
// guard covers the degenerate single-oversized-entry case).
while (cache.bytes > *DECODED_CACHE_BUDGET || cache.order.len() > DECODED_CACHE_CAP)
&& cache.order.len() > 1
{
let oldest = cache.order.remove(0);
cache.map.remove(&oldest);
if let Some(evicted) = cache.map.remove(&oldest) {
cache.bytes = cache.bytes.saturating_sub(evicted.0.len());
}
}
}
@@ -699,6 +742,33 @@ pub async fn fetch_and_decode(
fetch_and_decode_ref(&ArtworkRef::Remote(url.to_string()), cache, decode_size).await
}
/// Decode a local cover file to `decode_size` RGBA pixels, through the
/// decoded-pixel cache (keyed by path). Synchronous and `Send`-safe (no
/// `slint::Image`), so worker threads can pre-decode row covers and the
/// event loop builds the image via [`pixels_to_image`].
pub fn decode_local_pixels(path: &str, decode_size: u32) -> Option<DecodedPixels> {
if path.is_empty() {
return None;
}
if let Some(hit) = decoded_pixels(path, decode_size) {
return Some(hit);
}
let bytes = std::fs::read(path).ok()?;
let (pixels, w, h) = decode_rgba(&bytes, decode_size)?;
let entry: DecodedPixels = (Arc::new(pixels), w, h);
store_decoded(path, decode_size, &entry);
Some(entry)
}
/// Bounded replacement for `slint::Image::load_from_path` on synchronous
/// UI-thread call sites: decodes to `decode_size` so only thumbnail-sized
/// pixels are retained for the model row's lifetime (`load_from_path` keeps
/// the full-resolution source in the image buffer). `None` on empty path /
/// missing file / decode failure — callers keep their fallback semantics.
pub fn load_local_cover(path: &str, decode_size: u32) -> Option<slint::Image> {
decode_local_pixels(path, decode_size).map(|(pixels, w, h)| pixels_to_image(&pixels, w, h))
}
/// Representative color of decoded RGBA pixels for the header gradient.
///
/// A plain average desaturates badly (everything trends grey), so the
+7 -6
View File
@@ -302,16 +302,17 @@ where
.collect();
// Qobuz Playlists row — both the Home and Editor's Picks tabs draw from
// the SAME `containers.playlists` (one fetch). Capped at 100 (raised from
// Tauri's 18) so the client-side category filter has material to work with;
// the carousel still pages, and the un-filtered view shows the same first
// cards. Each card carries its tag slugs for the filter.
// the SAME `containers.playlists` (one fetch). Capped at 40 (raised from
// Tauri's 18) so the client-side category filter has material to work with
// without holding 100 cards' covers in memory; the carousel still pages,
// and the un-filtered view shows the same first cards. Each card carries
// its tag slugs for the filter.
let playlist_items: Vec<DiscoverPlaylist> =
containers.playlists.map(|c| c.data.items).unwrap_or_default();
let editor_playlists: Vec<PlaylistCardData> =
playlist_items.iter().cloned().take(100).map(map_playlist).collect();
playlist_items.iter().cloned().take(40).map(map_playlist).collect();
let playlists: Vec<PlaylistCardData> =
playlist_items.into_iter().take(100).map(map_playlist).collect();
playlist_items.into_iter().take(40).map(map_playlist).collect();
// Category tags for the multi-select filter (slug + localized name).
let playlist_tags: Vec<(String, String)> = containers
+3 -1
View File
@@ -3554,12 +3554,14 @@ fn folder_display_name(path: &str) -> String {
}
/// Load a cover from a cached artwork path (strips an optional `file://`).
/// Decoded to the rows tier (the album header renders it at 56px) so the
/// ephemeral model never retains full-resolution sources.
fn load_cover(path: &Option<String>) -> slint::Image {
let Some(p) = path.as_deref().filter(|s| !s.is_empty()) else {
return slint::Image::default();
};
let p = p.strip_prefix("file://").unwrap_or(p);
slint::Image::load_from_path(std::path::Path::new(p)).unwrap_or_default()
crate::artwork::load_local_cover(p, 96).unwrap_or_default()
}
/// Group ephemeral tracks into album blocks (sorted by title), each with its
+5 -3
View File
@@ -1176,12 +1176,13 @@ pub fn apply(window: &AppWindow, data: LocalPlaylistData) {
state.set_is_local(true);
state.set_offline_only(data.offline_only);
state.set_is_owner(true);
// Custom artwork (local file) or the row-collage fallback.
// Custom artwork (local file) or the row-collage fallback. Decoded to the
// card tier (the header cover renders at 150px).
let custom = data
.custom_artwork_path
.as_ref()
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
.and_then(|p| crate::artwork::load_local_cover(p, 264));
if let Some(img) = custom {
state.set_cover(img);
state.set_cover_url(data.custom_artwork_path.clone().unwrap_or_default().into());
@@ -1476,10 +1477,11 @@ fn apply_qobuz_offline(
// Read-only offline: Qobuz-side edits (rename / remove tracks / custom
// order writes) can't reach the API, so the owner affordances hide.
state.set_is_owner(false);
// Card-tier decode (150px header cover), matching `apply`.
let custom = custom_artwork_path
.as_ref()
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
.and_then(|p| crate::artwork::load_local_cover(p, 264));
if let Some(img) = custom {
state.set_cover(img);
state.set_cover_url(custom_artwork_path.unwrap_or_default().into());
+4 -1
View File
@@ -317,12 +317,15 @@ fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
// missing/undecodable path is treated as "no custom cover" so the mosaic
// shows instead of an empty full-bleed square — and so `has_custom` drives
// cover_count + the URL closure consistently below.
// Decoded to the card tier (the grid card renders at 184px) so a rebuild
// per keystroke/sort never retains full-resolution sources; the decoded-
// pixel cache makes the repeats a lookup.
let decoded_custom = c
.custom_artwork_path
.as_ref()
.filter(|p| !p.is_empty())
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
.and_then(|p| crate::artwork::load_local_cover(p, 264));
let (custom_image, has_custom) = match decoded_custom {
Some(img) => (img, true),
None => (slint::Image::default(), false),
+3 -2
View File
@@ -1164,13 +1164,14 @@ pub fn apply(window: &AppWindow, c: MixtapeCollection) {
state.set_found(true);
// Custom cover (overrides the mosaic) — load the local file directly (it
// lives in the artwork cache on disk; same as the playlist controller).
// lives in the artwork cache on disk; same as the playlist controller),
// decoded to the card tier (the hero renders it at 186px).
let has_custom = c
.custom_artwork_path
.as_ref()
.filter(|p| !p.is_empty())
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
.and_then(|p| crate::artwork::load_local_cover(p, 264));
if let Some(img) = has_custom {
state.set_has_custom_cover(true);
state.set_custom_cover(img);
+16 -13
View File
@@ -39,22 +39,19 @@ type Runtime = std::sync::Arc<qbz_app::shell::AppRuntime<SlintAdapter>>;
/// `play_tracks` track-list semantics).
static RAIL_QUEUE: LazyLock<Mutex<Vec<QueueTrack>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Worker-built, `Send` row data; the cover image is decoded on the UI
/// thread (`slint::Image` is not `Send`) — the offline_manager pattern.
/// Worker-built, `Send` row data; the cover is pre-decoded to size on the
/// worker (`DecodedPixels` is `Send`) and the `slint::Image` is built on the
/// UI thread — the offline_manager pattern.
struct RowData {
id: String,
title: String,
artist: String,
cover_path: String,
cover: Option<crate::artwork::DecodedPixels>,
}
/// Load a cover image from a path on the UI thread (empty/missing -> default).
fn load_cover(path: &str) -> slint::Image {
if path.is_empty() {
return slint::Image::default();
}
slint::Image::load_from_path(std::path::Path::new(path)).unwrap_or_default()
}
/// Rail rows (SlimCard) render their cover at 44px; decode to the rows tier
/// so the model never holds full-resolution sources.
const COVER_DECODE_SIZE: u32 = 96;
/// kHz normalization: Qobuz metadata carries kHz (96.0), library rows Hz
/// (96000.0) — same defensive rule as `local_queue_track`.
@@ -144,7 +141,7 @@ pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
id: t.track_id.to_string(),
title: t.title.clone(),
artist: t.artist.clone(),
cover_path: cover,
cover: crate::artwork::decode_local_pixels(&cover, COVER_DECODE_SIZE),
});
}
for lt in &lib_rows {
@@ -159,7 +156,10 @@ pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
id: qid.to_string(),
title: lt.title.clone(),
artist: lt.artist.clone(),
cover_path: lt.artwork_path.clone().unwrap_or_default(),
cover: crate::artwork::decode_local_pixels(
lt.artwork_path.as_deref().unwrap_or_default(),
COVER_DECODE_SIZE,
),
});
}
@@ -195,7 +195,10 @@ pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
subtitle: rd.artist.into(),
rank: "".into(),
artwork_url: "".into(),
artwork: load_cover(&rd.cover_path),
artwork: rd
.cover
.map(|(px, pw, ph)| crate::artwork::pixels_to_image(&px, pw, ph))
.unwrap_or_default(),
following: false,
})
.collect();
+15 -16
View File
@@ -83,22 +83,18 @@ fn album_size(group: &[CachedTrackInfo]) -> u64 {
/// Resolution order (B5): the index's `artwork_path` when set, the CMAF
/// bundle's `tracks-cmaf/<id>/cover.jpg`, then the `cover.jpg` sibling of
/// the audio file (v1-format rows). Computed on the worker (a `String` is
/// `Send`); the image itself is loaded on the UI thread (`slint::Image` is
/// NOT `Send`).
/// `Send`).
fn cover_path(cache_path: &str, track: &CachedTrackInfo) -> String {
track.resolve_cover_path(cache_path).unwrap_or_default()
}
/// Load a cover image from a path on the UI thread (empty path / missing -> default).
fn load_cover(path: &str) -> slint::Image {
if path.is_empty() {
return slint::Image::default();
}
slint::Image::load_from_path(std::path::Path::new(path)).unwrap_or_default()
}
/// Album rows render their cover at 40px; decode to the rows tier so the
/// model holds ~36KB per cover instead of the full-resolution source.
const COVER_DECODE_SIZE: u32 = 96;
/// Worker-built, `Send` row data. Converted to the (non-`Send`) `OfflineRow`
/// on the UI thread, where the cover image is decoded.
/// on the UI thread; the cover is pre-decoded to size on the worker
/// (`DecodedPixels` is `Send`, `slint::Image` is not).
struct RowData {
kind: &'static str,
album_id: String,
@@ -108,7 +104,7 @@ struct RowData {
meta: String,
status: i32,
progress: f32,
cover_path: String,
cover: Option<crate::artwork::DecodedPixels>,
number: String,
}
@@ -234,7 +230,7 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
),
status: album_status,
progress: 0.0,
cover_path,
cover: crate::artwork::decode_local_pixels(&cover_path, COVER_DECODE_SIZE),
number: String::new(),
});
for (i, t) in group.iter().enumerate() {
@@ -250,7 +246,7 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
meta: fmt_duration(t.duration_secs),
status: track_status_int(&t.status),
progress: t.progress_percent as f32 / 100.0,
cover_path: String::new(),
cover: None,
number: (i + 1).to_string(),
});
}
@@ -268,8 +264,8 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
let _ = weak.upgrade_in_event_loop(move |w| {
let st = w.global::<OfflineManagerState>();
// Build OfflineRow on the UI thread (decodes the cover images here —
// slint::Image is not Send, so it can't be built on the worker).
// Build OfflineRow on the UI thread (slint::Image is not Send, so it
// can't be built on the worker); the pixels were decoded there.
let offline_rows: Vec<OfflineRow> = rows
.into_iter()
.map(|rd| OfflineRow {
@@ -281,7 +277,10 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
meta: rd.meta.into(),
status: rd.status,
progress: rd.progress,
cover: load_cover(&rd.cover_path),
cover: rd
.cover
.map(|(px, pw, ph)| crate::artwork::pixels_to_image(&px, pw, ph))
.unwrap_or_default(),
number: rd.number.into(),
selected: false,
})
+3 -2
View File
@@ -651,12 +651,13 @@ pub fn apply(window: &AppWindow, data: PlaylistData) {
crate::local_playlist::clear_open_snapshot();
}
// Custom artwork overrides the collage / server image. Load the
// local file directly (it lives in the artwork cache on disk).
// local file directly (it lives in the artwork cache on disk),
// decoded to the card tier (the header cover renders at 150px).
let custom = data
.custom_artwork_path
.as_ref()
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
.and_then(|p| crate::artwork::load_local_cover(p, 264));
let state = window.global::<PlaylistState>();
state.set_id(data.id.into());
state.set_name(data.name.into());