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:
9 files changed
+136
-54
No files matched your search
+80
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user