1use crate::media::{is_image_path, Backend, DecoderPool, Frame};
11use eframe::egui;
12use std::collections::{HashMap, HashSet};
13use std::hash::{Hash, Hasher};
14use std::sync::{Arc, Condvar, Mutex};
15
16const CAP: usize = 512;
18const BUDGET: usize = 48 << 20;
20
21struct Entry {
22 frame: Option<Arc<Frame>>,
24 tick: u64,
26}
27
28struct Shared {
29 ready: HashMap<u64, Entry>,
30 queue: Vec<(u64, String, f64, u32)>,
32 pending: HashSet<u64>,
33 evicted: Vec<u64>,
35 tick: u64,
36 backend: Backend,
37 epoch: u64,
39 clear_pool: bool,
41 quit: bool,
42}
43
44fn key_of(path: &str, t: f64, h: u32) -> u64 {
46 let mut hs = std::collections::hash_map::DefaultHasher::new();
47 (path, (t * 2.0).round() as i64, h).hash(&mut hs);
48 hs.finish()
49}
50
51fn qh(h: u32) -> u32 {
54 h.max(1).next_multiple_of(16)
55}
56
57fn bucket_time(path: &str, t: f64) -> f64 {
59 if is_image_path(path) {
60 0.0
61 } else {
62 (t * 2.0).round().max(0.0) / 2.0
63 }
64}
65
66pub struct ThumbCache {
67 shared: Arc<(Mutex<Shared>, Condvar)>,
68 textures: HashMap<u64, (egui::TextureHandle, [u32; 2])>,
70}
71
72impl ThumbCache {
73 pub fn new(ctx: egui::Context, backend: Backend) -> Self {
74 let shared = Arc::new((
75 Mutex::new(Shared {
76 ready: HashMap::new(),
77 queue: Vec::new(),
78 pending: HashSet::new(),
79 evicted: Vec::new(),
80 tick: 0,
81 backend,
82 epoch: 0,
83 clear_pool: false,
84 quit: false,
85 }),
86 Condvar::new(),
87 ));
88 let s = shared.clone();
89 let _ = std::thread::Builder::new().name("thumbs".into()).spawn(move || worker(s, ctx));
90 Self { shared, textures: HashMap::new() }
91 }
92
93 pub fn set_backend(&mut self, b: Backend) {
94 {
95 let Ok(mut st) = self.shared.0.lock() else { return };
96 if st.backend == b {
97 return;
98 }
99 st.backend = b;
100 }
101 self.clear();
103 }
104
105 pub fn get(&mut self, path: &str, t: f64, h: u32) -> Option<Arc<Frame>> {
107 let (t, h) = (bucket_time(path, t), qh(h));
108 let key = key_of(path, t, h);
109 let (result, evicted) = {
110 let mut st = self.shared.0.lock().ok()?;
111 st.tick += 1;
112 let tick = st.tick;
113 let result = match st.ready.get_mut(&key) {
114 Some(e) => {
115 e.tick = tick;
116 e.frame.clone() }
118 None => {
119 if st.pending.insert(key) {
120 st.queue.push((key, path.to_string(), t, h));
121 self.shared.1.notify_one();
122 }
123 None
124 }
125 };
126 (result, std::mem::take(&mut st.evicted))
127 };
128 for k in evicted {
129 self.textures.remove(&k);
130 }
131 result
132 }
133
134 pub fn texture(&mut self, ctx: &egui::Context, path: &str, t: f64, h: u32) -> Option<(egui::TextureId, [u32; 2])> {
136 let frame = self.get(path, t, h)?;
137 let key = key_of(path, bucket_time(path, t), qh(h));
138 if let Some((th, size)) = self.textures.get(&key) {
139 return Some((th.id(), *size));
140 }
141 let (w, hh) = (frame.width as usize, frame.height as usize);
142 if w == 0 || hh == 0 || frame.rgba.len() != w * hh * 4 {
143 return None;
144 }
145 let img = egui::ColorImage::from_rgba_premultiplied([w, hh], &frame.rgba);
146 let th = ctx.load_texture(format!("thumb{key:016x}"), img, egui::TextureOptions::LINEAR);
147 let (id, size) = (th.id(), [frame.width, frame.height]);
148 self.textures.insert(key, (th, size));
149 Some((id, size))
150 }
151
152 pub fn clear(&mut self) {
153 self.textures.clear();
154 if let Ok(mut st) = self.shared.0.lock() {
155 st.ready.clear();
156 st.queue.clear();
157 st.pending.clear();
158 st.evicted.clear();
159 st.epoch += 1;
160 st.clear_pool = true;
161 }
162 self.shared.1.notify_one();
163 }
164}
165
166impl Drop for ThumbCache {
167 fn drop(&mut self) {
168 if let Ok(mut st) = self.shared.0.lock() {
169 st.quit = true;
170 }
171 self.shared.1.notify_all();
172 }
174}
175
176enum Job {
177 Quit,
178 Clear,
179 Decode(u64, String, f64, u32, Backend, u64),
180}
181
182fn worker(shared: Arc<(Mutex<Shared>, Condvar)>, ctx: egui::Context) {
183 let mut pool: Option<DecoderPool> = None;
184 loop {
185 let job = {
186 let Ok(mut st) = shared.0.lock() else { return };
187 loop {
188 if st.quit {
189 break Job::Quit;
190 }
191 if st.clear_pool {
192 st.clear_pool = false;
193 break Job::Clear;
194 }
195 if let Some((k, p, t, h)) = st.queue.pop() {
196 break Job::Decode(k, p, t, h, st.backend, st.epoch);
197 }
198 match shared.1.wait(st) {
199 Ok(g) => st = g,
200 Err(_) => return,
201 }
202 }
203 };
204 match job {
205 Job::Quit => return,
206 Job::Clear => pool = None, Job::Decode(key, path, t, h, backend, epoch) => {
208 let p = pool.get_or_insert_with(|| DecoderPool::new(backend));
209 p.set_backend(backend); let frame =
213 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| decode_thumb(p, &path, t, h))) {
214 Ok(f) => f,
215 Err(_) => {
216 pool = None;
217 None
218 }
219 };
220 let Ok(mut st) = shared.0.lock() else { return };
221 if st.epoch != epoch {
222 continue; }
224 st.pending.remove(&key);
225 st.tick += 1;
226 let tick = st.tick;
227 st.ready.insert(key, Entry { frame: frame.map(Arc::new), tick });
228 evict(&mut st);
229 drop(st);
230 ctx.request_repaint();
231 }
232 }
233 }
234}
235
236fn decode_thumb(pool: &mut DecoderPool, path: &str, t: f64, h: u32) -> Option<Frame> {
238 let v = pool.video(path)?;
239 let (w0, h0) = v.size();
240 if w0 == 0 || h0 == 0 {
241 return None;
242 }
243 let h = h.clamp(1, 2160);
244 let w = ((w0 as f64 * h as f64 / h0 as f64).round() as u32).max(1);
245 let mut out = Frame::new(w, h);
246 out.pts = t;
247 if v.frame_at(t, w, h, &mut out) {
248 Some(out)
249 } else {
250 None
251 }
252}
253
254fn evict(st: &mut Shared) {
258 let live = |e: &Entry| e.frame.as_ref().map(|f| f.rgba.len());
259 let mut bytes: usize = st.ready.values().filter_map(live).sum();
260 let mut count = st.ready.values().filter(|e| e.frame.is_some()).count();
261 while bytes > BUDGET || count > CAP {
262 let Some((&k, _)) = st.ready.iter().filter(|(_, e)| e.frame.is_some()).min_by_key(|(_, e)| e.tick) else {
263 return;
264 };
265 bytes -= st.ready.remove(&k).as_ref().and_then(live).unwrap_or(0);
266 count -= 1;
267 st.evicted.push(k);
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::time::{Duration, Instant};
275
276 fn poll(c: &mut ThumbCache, path: &str, t: f64, h: u32, secs: u64) -> Option<Arc<Frame>> {
277 let deadline = Instant::now() + Duration::from_secs(secs);
278 loop {
279 if let Some(f) = c.get(path, t, h) {
280 return Some(f);
281 }
282 if Instant::now() >= deadline {
283 return None;
284 }
285 let done = {
286 let st = c.shared.0.lock().unwrap();
287 st.pending.is_empty() && st.queue.is_empty()
288 };
289 if done {
290 return c.get(path, t, h);
292 }
293 std::thread::sleep(Duration::from_millis(10));
294 }
295 }
296
297 fn centre(f: &Frame) -> [u8; 3] {
298 let i = ((f.height / 2 * f.width + f.width / 2) * 4) as usize;
299 [f.rgba[i], f.rgba[i + 1], f.rgba[i + 2]]
300 }
301
302 #[test]
303 fn video_thumbs_colours_aspect_and_cache() {
304 let path = crate::media::ffpipe::tests::test_mp4(); let ctx = egui::Context::default();
306 let mut c = ThumbCache::new(ctx.clone(), Backend::Ffmpeg);
307 assert!(c.get(&path, 0.5, 48).is_none(), "first get must not block");
308 let red = poll(&mut c, &path, 0.5, 48, 3).expect("thumb within 3 s");
309 assert_eq!((red.width, red.height), (64, 48), "aspect kept");
310 let [r, g, _] = centre(&red);
311 assert!(r > 180 && g < 90, "red at 0.5 s, got {:?}", centre(&red));
312 let green = poll(&mut c, &path, 2.5, 48, 3).expect("second thumb");
313 let [r, g, _] = centre(&green);
314 assert!(g > 180 && r < 90, "green at 2.5 s, got {:?}", centre(&green));
315 assert!(c.get(&path, 0.5, 48).is_some());
317 assert!(c.get(&path, 0.6, 48).is_some(), "same 0.5 s bucket");
318 {
319 let st = c.shared.0.lock().unwrap();
320 assert!(st.queue.is_empty() && st.pending.is_empty());
321 }
322 let (id, size) = c.texture(&ctx, &path, 0.5, 48).unwrap();
324 assert_eq!(size, [64, 48]);
325 let (id2, _) = c.texture(&ctx, &path, 0.5, 48).unwrap();
326 assert_eq!(id, id2);
327 c.clear();
329 assert!(c.textures.is_empty());
330 assert!(c.get(&path, 0.5, 48).is_none());
331 assert!(poll(&mut c, &path, 0.5, 48, 5).is_some(), "recomputes after clear");
332 }
333
334 #[test]
335 fn failed_decode_memoised() {
336 let mut c = ThumbCache::new(egui::Context::default(), Backend::Ffmpeg);
337 assert!(poll(&mut c, "Z:/definitely/missing.mp4", 0.0, 48, 10).is_none());
338 let deadline = Instant::now() + Duration::from_secs(10);
340 loop {
341 let st = c.shared.0.lock().unwrap();
342 if st.pending.is_empty() {
343 break;
344 }
345 drop(st);
346 assert!(Instant::now() < deadline, "failure never resolved");
347 std::thread::sleep(Duration::from_millis(10));
348 }
349 assert!(c.get("Z:/definitely/missing.mp4", 0.0, 48).is_none());
351 let st = c.shared.0.lock().unwrap();
352 assert!(st.queue.is_empty() && st.pending.is_empty(), "no respawn loop");
353 assert_eq!(st.ready.len(), 1, "failure memoised");
354 }
355
356 #[test]
357 fn image_ignores_t() {
358 let png = std::path::Path::new(&crate::media::ffpipe::tests::test_mp4())
359 .with_file_name(format!("{}-thumb.png", std::process::id()));
360 let st = std::process::Command::new("ffmpeg")
361 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=blue:s=64x48", "-frames:v", "1"])
362 .arg(&png)
363 .status()
364 .expect("ffmpeg on PATH");
365 assert!(st.success());
366 let png = png.to_string_lossy().into_owned();
367 let mut c = ThumbCache::new(egui::Context::default(), Backend::Ffmpeg);
368 let f = poll(&mut c, &png, 7.0, 24, 10).expect("image thumb");
369 assert_eq!((f.width, f.height), (43, 32), "64x48 source at qh(24) = 32 px tall");
370 let [r, _, b] = centre(&f);
371 assert!(b > 180 && r < 90, "blue, got {:?}", centre(&f));
372 assert!(c.get(&png, 123.0, 24).is_some());
374 assert!(c.get(&png, 0.0, 24).is_some());
375 let stt = c.shared.0.lock().unwrap();
376 assert_eq!(stt.ready.len(), 1);
377 }
378
379 fn empty_shared() -> Shared {
380 Shared {
381 ready: HashMap::new(),
382 queue: Vec::new(),
383 pending: HashSet::new(),
384 evicted: Vec::new(),
385 tick: 0,
386 backend: Backend::Ffmpeg,
387 epoch: 0,
388 clear_pool: false,
389 quit: false,
390 }
391 }
392
393 #[test]
394 fn lru_evicts_and_drops_textures() {
395 let mut st = empty_shared();
397 for i in 0..(CAP as u64 + 3) {
398 st.ready.insert(i, Entry { frame: Some(Arc::new(Frame::new(1, 1))), tick: i });
399 }
400 st.ready.insert(9999, Entry { frame: None, tick: 0 }); evict(&mut st);
402 assert_eq!(st.ready.values().filter(|e| e.frame.is_some()).count(), CAP);
403 assert_eq!(st.evicted.len(), 3);
404 assert!(st.evicted.contains(&0) && st.evicted.contains(&1) && st.evicted.contains(&2));
406 assert!(st.ready.contains_key(&9999));
407 }
408
409 #[test]
411 fn lru_evicts_on_byte_budget() {
412 let mut st = empty_shared();
413 let big = Arc::new(Frame::new(512, 512)); for i in 0..64u64 {
415 st.ready.insert(i, Entry { frame: Some(big.clone()), tick: i });
416 }
417 evict(&mut st);
418 let bytes: usize = st.ready.values().filter_map(|e| e.frame.as_ref()).map(|f| f.rgba.len()).sum();
419 assert!(bytes <= BUDGET, "{bytes} > {BUDGET}");
420 assert_eq!(st.ready.len(), BUDGET >> 20, "kept the 48 newest, count cap never fired");
421 assert!(st.evicted.contains(&0) && !st.evicted.contains(&63), "oldest first");
422 }
423
424 #[test]
426 fn height_quantised() {
427 assert_eq!([qh(0), qh(1), qh(16), qh(17), qh(40)], [16, 16, 16, 32, 48]);
428 assert!((1..1000).all(|h| qh(h) >= h), "never upscaled on draw");
429 let keys: HashSet<u64> = (24..=300).map(|h| key_of("a.mp4", 0.5, qh(h))).collect();
431 assert_eq!(keys.len(), 18);
432 assert_eq!(key_of("a.mp4", 0.5, qh(41)), key_of("a.mp4", 0.5, qh(48)));
434 }
435}