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:
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,