perf(app,core): bounded image cache, shrunk poster cache, in-place watch updates

- ImageCache drops the picture asked for longest ago past 256 MiB and
  uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
  a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
  it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
This commit is contained in:
nvrl committed 2026-10-02 19:34:45 +02:00
1 parent 4027ab5fb3
commit ff362d0d08
5 files changed
+441 -116

No files matched your search

+356 -101
View File
@@ -18,7 +18,8 @@
//! lands in the channel and sits there until the viewer happens to move the
//! mouse.
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
@@ -237,6 +238,12 @@ pub enum Event {
},
/// That player is gone: its window was closed, or playback ended.
PlayerStopped { id: u64 },
/// One file's watch state was saved, for the library to patch in place.
Watched {
share_id: String,
link_id: String,
state: WatchState,
},
/// Something the viewer should see, phrased for them.
Error(String),
/// Something the viewer might like to see, briefly.
@@ -244,6 +251,10 @@ pub enum Event {
/// Full download-manager snapshot. Replacing rather than patching avoids
/// stale rows when a task is removed.
Downloads(Vec<DownloadItem>),
/// One running download moved on. Sent instead of a snapshot for byte
/// progress, which arrives several times a second per download, while the
/// list it would copy and sort holds every file ever kept offline.
DownloadProgress(Box<DownloadItem>),
/// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>),
}
@@ -685,8 +696,10 @@ impl Engine {
};
match result {
Ok((files, watch)) => {
engine.emit(Event::LibraryLoaded(Library::build(files, &watch)));
engine.load_offline_files();
let library = Library::build(files, &watch);
let targets = download_targets(&library);
engine.emit(Event::LibraryLoaded(library));
engine.refresh_offline(Some(targets));
}
Err(error) => engine.fail("read the catalog", error),
}
@@ -696,70 +709,74 @@ impl Engine {
/// Refresh the lightweight offline index used by title badges and actions.
pub fn load_offline_files(&self) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
let result = (|| {
let catalog = engine.catalog.lock();
let files = catalog.all_offline_files()?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
let mut targets = HashMap::new();
for title in &library.titles {
for episode in title.episodes() {
let target = PlaybackTarget::new(title, episode);
targets.insert(DownloadKey::from(&target), target);
self.runtime
.spawn_blocking(move || engine.refresh_offline(None));
}
/// The offline index, against `targets` when the caller has just built a
/// library to take them from — otherwise one is built here, which on a
/// large catalog is most of what this costs.
fn refresh_offline(&self, targets: Option<HashMap<DownloadKey, PlaybackTarget>>) {
let result = (|| {
let catalog = self.catalog.lock();
let files = catalog.all_offline_files()?;
let targets = match targets {
Some(targets) => targets,
None => download_targets(&Library::build(
catalog.all_files()?,
&catalog.all_watch_states()?,
)),
};
let mut valid = HashSet::new();
let mut hydrated = Vec::new();
for ((share_id, link_id), file) in files {
let key = DownloadKey { share_id, link_id };
let path = self
.dirs
.offline_file(&key.share_id, &key.link_id, &file.revision_id);
let Some(expected) = file
.block_sizes
.iter()
.try_fold(0_u64, |sum, size| sum.checked_add(*size))
else {
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
continue;
};
if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == expected) {
valid.insert(key.clone());
if let Some(target) = targets.get(&key).cloned() {
hydrated.push(DownloadItem {
key,
target,
state: DownloadState::Completed,
downloaded: expected,
total: expected,
rate: 0.0,
});
}
} else {
// The catalog is an index, not the bytes. A manually
// removed or truncated file must stop wearing an
// offline badge and remain playable from the share.
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
}
let mut valid = HashSet::new();
let mut hydrated = Vec::new();
for ((share_id, link_id), file) in files {
let key = DownloadKey { share_id, link_id };
let path =
engine
.dirs
.offline_file(&key.share_id, &key.link_id, &file.revision_id);
let Some(expected) = file
.block_sizes
.iter()
.try_fold(0_u64, |sum, size| sum.checked_add(*size))
else {
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
continue;
};
if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == expected) {
valid.insert(key.clone());
if let Some(target) = targets.get(&key).cloned() {
hydrated.push(DownloadItem {
key,
target,
state: DownloadState::Completed,
downloaded: expected,
total: expected,
rate: 0.0,
});
}
} else {
// The catalog is an index, not the bytes. A manually
// removed or truncated file must stop wearing an
// offline badge and remain playable from the share.
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
}
}
Ok::<_, pstr_core::Error>((valid, hydrated))
})();
match result {
Ok((files, hydrated)) => {
{
let mut downloads = engine.downloads.lock();
for item in hydrated {
downloads.items.entry(item.key.clone()).or_insert(item);
}
}
engine.hydrate_partial_downloads();
engine.emit_downloads();
engine.emit(Event::OfflineFiles(files));
}
Err(error) => engine.fail("read offline downloads", error),
}
});
Ok::<_, pstr_core::Error>((valid, hydrated))
})();
match result {
Ok((files, hydrated)) => {
{
let mut downloads = self.downloads.lock();
for item in hydrated {
downloads.items.entry(item.key.clone()).or_insert(item);
}
}
self.hydrate_partial_downloads();
self.emit_downloads();
self.emit(Event::OfflineFiles(files));
}
Err(error) => self.fail("read offline downloads", error),
}
}
/// Crawl one share, or all of them, and reload the library.
@@ -917,7 +934,13 @@ impl Engine {
.set_watch_state(share_id, link_id, state)
{
tracing::warn!("save watch state for {link_id}: {error}");
return;
}
self.emit(Event::Watched {
share_id: share_id.to_owned(),
link_id: link_id.to_owned(),
state: *state,
});
}
pub fn title_track_prefs(&self, key: &str) -> Option<TitleTrackPrefs> {
@@ -1259,6 +1282,20 @@ impl Engine {
self.emit_downloads();
}
/// [`Self::update_download`] for a change to one row only: the row goes
/// to the UI on its own rather than in a fresh snapshot of every row.
fn progress_download(&self, key: &DownloadKey, update: impl FnOnce(&mut DownloadItem)) {
let item = {
let mut downloads = self.downloads.lock();
let Some(item) = downloads.items.get_mut(key) else {
return;
};
update(item);
item.clone()
};
self.emit(Event::DownloadProgress(Box::new(item)));
}
fn partial_paths(&self, key: &DownloadKey) -> (std::path::PathBuf, std::path::PathBuf) {
let partial = self
.dirs
@@ -1457,7 +1494,7 @@ impl Engine {
{
let seconds = last_snapshot.elapsed().as_secs_f64();
let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON);
self.update_download(&key, |item| {
self.progress_download(&key, |item| {
item.state = DownloadState::Running;
item.downloaded = offset;
// Smoothed, or the time left jumps about with every block
@@ -1536,7 +1573,17 @@ impl Engine {
self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) {
Ok(image) => return engine.emit(Event::Thumbnail { key, image }),
Ok(decoded) => {
// A cache written before entries were stored shrunk
// holds the full preview; shrink it once, on its way out.
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
}
return engine.emit(Event::Thumbnail {
key,
image: decoded.image,
});
}
// A truncated or corrupt cache entry is not worth reporting;
// fall through and fetch it again.
Err(error) => tracing::debug!("cached thumbnail {key}: {error}"),
@@ -1579,13 +1626,14 @@ impl Engine {
return engine.emit(Event::ThumbnailMissing { key });
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(&path, &bytes).await;
match decode_thumbnail(&bytes) {
Ok(image) => engine.emit(Event::Thumbnail { key, image }),
Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Thumbnail {
key,
image: decoded.image,
})
}
Err(error) => {
tracing::debug!("decode thumbnail {key}: {error}");
engine.emit(Event::ThumbnailMissing { key });
@@ -1937,10 +1985,13 @@ impl Engine {
self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) {
Ok(image) => {
Ok(decoded) => {
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
}
return engine.emit(Event::Poster {
key: title_key,
image,
image: decoded.image,
});
}
Err(error) => tracing::debug!("cached poster {title_key}: {error}"),
@@ -1960,16 +2011,14 @@ impl Engine {
}
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(&path, &bytes).await;
match decode_thumbnail(&bytes) {
Ok(image) => engine.emit(Event::Poster {
key: title_key,
image,
}),
Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster {
key: title_key,
image: decoded.image,
})
}
Err(error) => {
tracing::debug!("decode poster for {title_key}: {error}");
engine.emit(Event::PosterMissing { key: title_key })
@@ -1979,6 +2028,18 @@ impl Engine {
}
}
/// Every file in `library`, by the key its download is filed under.
fn download_targets(library: &Library) -> HashMap<DownloadKey, PlaybackTarget> {
let mut targets = HashMap::new();
for title in &library.titles {
for episode in title.episodes() {
let target = PlaybackTarget::new(title, episode);
targets.insert(DownloadKey::from(&target), target);
}
}
targets
}
/// A short, filesystem-safe name for a URL.
fn digest(url: &str) -> String {
use sha2::{Digest as _, Sha256};
@@ -2117,24 +2178,61 @@ async fn write_marker_atomically(
Ok(())
}
/// A picture ready to upload, and — when it had to be scaled down — the
/// scaled copy encoded again, for the disk cache to keep instead.
struct Decoded {
image: egui::ColorImage,
smaller: Option<Vec<u8>>,
}
/// Decode image bytes into something egui can upload, scaled down to
/// [`THUMBNAIL_MAX_EDGE`].
///
/// Runs on the runtime rather than the UI thread: a JPEG decode per tile during
/// the first paint of a large library is visible as dropped frames.
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<egui::ColorImage> {
///
/// A provider's backdrop is often 1920 px or more, and decoding and resizing
/// that every launch was most of what painting a cached grid cost. The scaled
/// copy is handed back so the cache holds that instead, and the next launch
/// decodes a picture already the size it is drawn at.
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?;
let decoded = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE)
let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE);
let encoded = encode_for_cache(&scaled);
(scaled, encoded)
} else {
decoded
(decoded, None)
};
let rgba = decoded.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
Ok(egui::ColorImage::from_rgba_unmultiplied(
size,
rgba.as_flat_samples().as_slice(),
))
Ok(Decoded {
image: egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_flat_samples().as_slice()),
smaller,
})
}
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
/// holding the original — slower to load, never wrong.
fn encode_for_cache(image: &image::DynamicImage) -> Option<Vec<u8>> {
let mut bytes = std::io::Cursor::new(Vec::new());
let encoded = if image.color().has_alpha() {
image.write_to(&mut bytes, image::ImageFormat::Png)
} else {
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 88)
.encode_image(&image.to_rgb8())
};
encoded.ok().map(|()| bytes.into_inner())
}
/// Write a cached picture. Best effort: the cache is a convenience, and a
/// write that fails only means the next launch fetches it again.
async fn store_image(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(path, bytes).await;
}
/// Watch state as of now, for a position in a file.
@@ -2168,14 +2266,58 @@ pub fn describe_failures(failures: &[(String, String)]) -> Option<String> {
/// must be remembered as not existing. Without that second half, every render
/// pays a round-trip per unmatched tile, which is exactly what
/// `proton-drive-linux`'s photo grid had to learn.
#[derive(Default)]
///
/// It is also bounded. Scrolling a large library once used to keep a texture
/// for every title in it for the rest of the session; past [`IMAGE_BUDGET`]
/// bytes the picture asked for longest ago goes, and comes back from the disk
/// cache if it is asked for again.
pub struct ImageCache {
textures: HashMap<String, egui::TextureHandle>,
requested: std::collections::HashSet<String>,
missing: std::collections::HashSet<String>,
textures: HashMap<String, Entry>,
requested: HashSet<String>,
missing: HashSet<String>,
/// Decoded, waiting for a frame with upload budget left.
pending: VecDeque<(String, egui::ColorImage)>,
/// A logical clock: one tick per lookup, so "asked for longest ago" needs
/// no wall time.
clock: u64,
bytes: usize,
budget: usize,
}
struct Entry {
texture: egui::TextureHandle,
used: u64,
bytes: usize,
}
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
/// a megabyte, so this is a few hundred tiles — several screens of grid.
const IMAGE_BUDGET: usize = 256 << 20;
/// Textures uploaded per frame. A cached grid answers dozens of pictures at
/// once, and uploading them all in one frame is a visible hitch; spread over a
/// few frames they arrive faster than the eye follows.
const UPLOADS_PER_FRAME: usize = 6;
impl Default for ImageCache {
fn default() -> Self {
Self::with_budget(IMAGE_BUDGET)
}
}
impl ImageCache {
fn with_budget(budget: usize) -> Self {
Self {
textures: HashMap::new(),
requested: HashSet::new(),
missing: HashSet::new(),
pending: VecDeque::new(),
clock: 0,
bytes: 0,
budget,
}
}
/// The texture for `key`, calling `fetch` the first time it is asked for.
///
/// `None` while a fetch is in flight, and forever for a picture that turned
@@ -2184,8 +2326,10 @@ impl ImageCache {
/// Borrows the key: this is asked once per tile per frame, and a hit — the
/// usual answer — should not cost an allocation.
pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option<egui::TextureHandle> {
if let Some(texture) = self.textures.get(key) {
return Some(texture.clone());
self.clock += 1;
if let Some(entry) = self.textures.get_mut(key) {
entry.used = self.clock;
return Some(entry.texture.clone());
}
if !self.missing.contains(key) && !self.requested.contains(key) {
self.requested.insert(key.to_owned());
@@ -2194,9 +2338,56 @@ impl ImageCache {
None
}
pub fn insert(&mut self, ctx: &egui::Context, key: String, image: egui::ColorImage) {
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.textures.insert(key, texture);
/// Queue a decoded picture. It becomes a texture in [`Self::upload`].
pub fn insert(&mut self, key: String, image: egui::ColorImage) {
// Forgotten while it was being fetched: the answer is for a question
// nobody is asking any more.
if self.requested.contains(&key) {
self.pending.push_back((key, image));
}
}
/// Upload up to this frame's share of queued pictures. Returns whether any
/// are still waiting, so the caller can ask for another frame.
pub fn upload(&mut self, ctx: &egui::Context) -> bool {
for _ in 0..UPLOADS_PER_FRAME {
let Some((key, image)) = self.pending.pop_front() else {
break;
};
let bytes = image.pixels.len() * 4;
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.clock += 1;
let entry = Entry {
texture,
used: self.clock,
bytes,
};
if let Some(old) = self.textures.insert(key, entry) {
self.bytes -= old.bytes;
}
self.bytes += bytes;
self.evict();
}
!self.pending.is_empty()
}
/// Drop the pictures asked for longest ago until the rest fit the budget.
fn evict(&mut self) {
while self.bytes > self.budget && self.textures.len() > 1 {
let Some(oldest) = self
.textures
.iter()
.min_by_key(|(_, entry)| entry.used)
.map(|(key, _)| key.clone())
else {
break;
};
if let Some(entry) = self.textures.remove(&oldest) {
self.bytes -= entry.bytes;
}
// Asked for again, it is fetched again — from the disk cache.
self.requested.remove(&oldest);
}
}
pub fn mark_missing(&mut self, key: String) {
@@ -2205,9 +2396,12 @@ impl ImageCache {
/// Forget one picture, so the next ask fetches it again.
pub fn forget(&mut self, key: &str) {
self.textures.remove(key);
if let Some(entry) = self.textures.remove(key) {
self.bytes -= entry.bytes;
}
self.requested.remove(key);
self.missing.remove(key);
self.pending.retain(|(pending, _)| pending != key);
}
/// Forget everything. Used when the catalog is replaced, so a recrawl that
@@ -2217,6 +2411,67 @@ impl ImageCache {
self.textures.clear();
self.requested.clear();
self.missing.clear();
self.pending.clear();
self.bytes = 0;
}
}
#[cfg(test)]
mod image_cache_tests {
use super::*;
fn picture() -> egui::ColorImage {
egui::ColorImage::filled([4, 4], egui::Color32::WHITE)
}
#[test]
fn a_full_cache_drops_the_picture_asked_for_longest_ago() {
let ctx = egui::Context::default();
// Room for two 4×4 pictures.
let mut cache = ImageCache::with_budget(2 * 64);
for key in ["a", "b"] {
cache.texture(key, || {});
cache.insert(key.into(), picture());
}
cache.upload(&ctx);
// "a" is looked at again, so "b" is now the stale one.
assert!(cache.texture("a", || {}).is_some());
cache.texture("c", || {});
cache.insert("c".into(), picture());
cache.upload(&ctx);
assert!(cache.texture("a", || {}).is_some());
assert!(cache.texture("c", || {}).is_some());
let mut fetched = false;
assert!(cache.texture("b", || fetched = true).is_none());
assert!(
fetched,
"an evicted picture is fetched again when asked for"
);
}
#[test]
fn uploads_are_spread_over_frames() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
for index in 0..UPLOADS_PER_FRAME + 1 {
let key = format!("{index}");
cache.texture(&key, || {});
cache.insert(key, picture());
}
assert!(cache.upload(&ctx), "one picture is left for the next frame");
assert!(!cache.upload(&ctx));
}
#[test]
fn an_answer_for_a_forgotten_picture_is_dropped() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
cache.texture("a", || {});
cache.forget("a");
cache.insert("a".into(), picture());
cache.upload(&ctx);
assert!(cache.textures.is_empty());
}
}