notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 10a92cbb2548ec29053c1e31162cc271cabac70a
parent ee5040dcded0897df0da59140edea6c18d07a2c1
Author: kernelkind <kernelkind@gmail.com>
Date:   Sat, 22 Nov 2025 20:40:02 -0700

feat(gif): add AnimatedImgTexCache

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck/src/media/gif.rs | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/media/images.rs | 4++--
2 files changed, 228 insertions(+), 4 deletions(-)

diff --git a/crates/notedeck/src/media/gif.rs b/crates/notedeck/src/media/gif.rs @@ -1,12 +1,26 @@ use std::{ + collections::{HashMap, VecDeque}, + io::Cursor, + path::PathBuf, sync::mpsc::TryRecvError, time::{Instant, SystemTime}, }; -use crate::AnimationOld; +use crate::{ + jobs::{ + CompleteResponse, JobOutput, JobPackage, JobRun, MediaJobKind, MediaJobResult, + MediaJobSender, NoOutputRun, RunType, + }, + media::{ + images::{buffer_to_color_image, process_image}, + load_texture_checked, + }, + AnimationOld, Error, ImageFrame, ImageType, MediaCache, TextureFrame, TextureState, +}; use crate::{media::AnimationMode, Animation}; use crate::{GifState, GifStateMap, TextureStateOld, TexturedImage, TexturesCache}; -use egui::TextureHandle; +use egui::{ColorImage, TextureHandle}; +use image::{codecs::gif::GifDecoder, AnimationDecoder, DynamicImage, Frame}; use std::time::Duration; pub fn ensure_latest_texture_from_cache( @@ -249,3 +263,213 @@ pub fn ensure_latest_texture( } } } + +pub struct AnimatedImgTexCache { + pub(crate) cache: HashMap<String, TextureState<Animation>>, + animated_img_cache_path: PathBuf, +} + +impl AnimatedImgTexCache { + pub fn new(animated_img_cache_path: PathBuf) -> Self { + Self { + cache: Default::default(), + animated_img_cache_path, + } + } + + pub fn contains(&self, url: &str) -> bool { + self.cache.contains_key(url) + } + + pub fn get(&self, url: &str) -> Option<&TextureState<Animation>> { + self.cache.get(url) + } + + pub fn request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) { + let _ = self.get_or_request(jobs, ctx, url, imgtype); + } + + pub fn get_or_request( + &self, + jobs: &MediaJobSender, + ctx: &egui::Context, + url: &str, + imgtype: ImageType, + ) -> &TextureState<Animation> { + if let Some(res) = self.cache.get(url) { + return res; + }; + + let key = MediaCache::key(url); + let path = self.animated_img_cache_path.join(key); + let ctx = ctx.clone(); + let url = url.to_owned(); + if path.exists() { + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::AnimatedImg, + RunType::Output(JobRun::Sync(Box::new(move || { + from_disk_job_run(ctx, url, path) + }))), + )) { + tracing::error!("{e}"); + } + } else { + let anim_path = self.animated_img_cache_path.clone(); + if let Err(e) = jobs.send(JobPackage::new( + url.to_owned(), + MediaJobKind::AnimatedImg, + RunType::Output(JobRun::Async(Box::pin(from_net_run( + ctx, url, anim_path, imgtype, + )))), + )) { + tracing::error!("{e}"); + } + } + + &TextureState::Pending + } +} + +fn from_disk_job_run(ctx: egui::Context, url: String, path: PathBuf) -> JobOutput<MediaJobResult> { + tracing::trace!("Starting animated from disk job for {url}"); + let gif_bytes = match std::fs::read(path.clone()) { + Ok(b) => b, + Err(e) => { + return JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation(Err( + Error::Io(e), + )))) + } + }; + JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation( + generate_anim_pkg(ctx.clone(), url.to_owned(), gif_bytes, |img| { + buffer_to_color_image(img.as_flat_samples_u8(), img.width(), img.height()) + }) + .map(|f| f.anim), + ))) +} + +async fn from_net_run( + ctx: egui::Context, + url: String, + path: PathBuf, + imgtype: ImageType, +) -> JobOutput<MediaJobResult> { + let res = match crate::media::network::http_req(&url).await { + Ok(r) => r, + Err(e) => { + return JobOutput::complete(MediaJobResult::Animation(Err(crate::Error::Generic( + format!("Http error: {e}"), + )))); + } + }; + + JobOutput::Next(JobRun::Sync(Box::new(move || { + tracing::trace!("Starting animated img from net job for {url}"); + let animation = + match generate_anim_pkg(ctx.clone(), url.to_owned(), res.bytes, move |img| { + process_image(imgtype, img) + }) { + Ok(a) => a, + Err(e) => { + return JobOutput::Complete(CompleteResponse::new(MediaJobResult::Animation( + Err(e), + ))); + } + }; + JobOutput::Complete( + CompleteResponse::new(MediaJobResult::Animation(Ok(animation.anim))).run_no_output( + NoOutputRun::Sync(Box::new(move || { + tracing::trace!("writing animated texture to file for {url}"); + if let Err(e) = MediaCache::write_gif(&path, &url, animation.img_frames) { + tracing::error!("Could not write gif to disk: {e}"); + } + })), + ), + ) + }))) +} + +fn generate_anim_pkg( + ctx: egui::Context, + url: String, + gif_bytes: Vec<u8>, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> Result<AnimationPackage, Error> { + let decoder = { + let reader = Cursor::new(gif_bytes.as_slice()); + GifDecoder::new(reader)? + }; + + let frames: VecDeque<Frame> = decoder + .into_frames() + .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() + .map_err(|e| crate::Error::Generic(e.to_string()))?; + + let mut imgs = Vec::new(); + let mut other_frames = Vec::new(); + + let mut first_frame = None; + for (i, frame) in frames.into_iter().enumerate() { + let delay = frame.delay(); + let img = generate_color_img_frame(frame, process_to_egui); + imgs.push(ImageFrame { + delay: delay.into(), + image: img.clone(), + }); + + let tex_frame = generate_animation_frame(&ctx, &url, i, delay.into(), img); + + if first_frame.is_none() { + first_frame = Some(tex_frame); + } else { + other_frames.push(tex_frame); + } + } + + let Some(first_frame) = first_frame else { + return Err(crate::Error::Generic( + "first frame not found for gif".to_owned(), + )); + }; + + Ok(AnimationPackage { + anim: Animation { + first_frame, + other_frames, + }, + img_frames: imgs, + }) +} + +struct AnimationPackage { + anim: Animation, + img_frames: Vec<ImageFrame>, +} + +fn generate_color_img_frame( + frame: image::Frame, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> ColorImage { + let img = DynamicImage::ImageRgba8(frame.into_buffer()); + process_to_egui(img) +} + +fn generate_animation_frame( + ctx: &egui::Context, + url: &str, + index: usize, + delay: Duration, + color_img: ColorImage, +) -> TextureFrame { + TextureFrame { + delay, + texture: load_texture_checked(ctx, format!("{url}{index}"), color_img, Default::default()), + } +} diff --git a/crates/notedeck/src/media/images.rs b/crates/notedeck/src/media/images.rs @@ -137,7 +137,7 @@ fn resize_image_if_too_big( /// - resize if any larger, using [`resize_image_if_too_big`] /// #[profiling::function] -fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { +pub fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { const MAX_IMG_LENGTH: u32 = 2048; const FILTER_TYPE: FilterType = FilterType::CatmullRom; @@ -371,7 +371,7 @@ fn generate_animation_frame( } } -fn buffer_to_color_image( +pub fn buffer_to_color_image( samples: Option<FlatSamples<&[u8]>>, width: u32, height: u32,