simple_editor\media/
thumbs.rs

1//! Thumbnail cache for timeline filmstrips and library previews. Non-blocking: `get`/`texture` return
2//! immediately; misses are queued to ONE worker thread (newest request first, so scrubbing/zooming stays
3//! responsive) that owns a DecoderPool (backend from settings) and decodes `h`-pixel-tall RGBA thumbnails
4//! (aspect kept; images ignore `t`; Sequence clips are not handled here — the timeline draws a label).
5//! Results are kept in an LRU bounded by both entry count (≤512) and decoded bytes (≤48 MB) and uploaded
6//! lazily as egui textures (`texture`) so panels can paint them directly. `ctx.request_repaint()` when a
7//! thumbnail lands. Key = (path, t rounded to 0.5 s buckets, h rounded up to 16 px); `clear()` forgets
8//! everything (e.g. after save-over-original).
9
10use 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
16/// Max decoded thumbnails kept (failed lookups are memoised separately and never evicted).
17const CAP: usize = 512;
18/// Max decoded bytes kept — a 1080p source 300 px tall is 640 KB per entry, so CAP alone is not a bound.
19const BUDGET: usize = 48 << 20;
20
21struct Entry {
22    /// None = decode failed; memoised so a bad file/time is never retried.
23    frame: Option<Arc<Frame>>,
24    /// Last-used tick for LRU eviction.
25    tick: u64,
26}
27
28struct Shared {
29    ready: HashMap<u64, Entry>,
30    /// LIFO request stack (worker pops from the back → newest first).
31    queue: Vec<(u64, String, f64, u32)>,
32    pending: HashSet<u64>,
33    /// Keys evicted by the worker; the UI thread drains this to drop their textures.
34    evicted: Vec<u64>,
35    tick: u64,
36    backend: Backend,
37    /// Bumped by `clear()`; in-flight decodes from an older epoch are discarded.
38    epoch: u64,
39    /// Ask the worker to drop its decoders (releases file handles).
40    clear_pool: bool,
41    quit: bool,
42}
43
44/// ponytail: 64-bit hash as the map key so per-frame lookups allocate nothing — collisions are astronomically unlikely.
45fn 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
51/// ponytail: 16 px steps so a track-height drag reuses cached thumbs instead of queueing a decode per
52/// pixel of height; rounding up means the texture is never upscaled on draw.
53fn qh(h: u32) -> u32 {
54    h.max(1).next_multiple_of(16)
55}
56
57/// The half-second bucket time actually decoded for a request at `t` (0 for images).
58fn 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    /// egui textures for ready thumbnails, created lazily by `texture()` (UI thread only).
69    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        // new backend may decode what the old one couldn't → forget everything (incl. failed memos)
102        self.clear();
103    }
104
105    /// Thumbnail frame of `path` at source time `t`, `h` px tall. None while computing (request queued).
106    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() // None here = memoised failure: do not re-queue
117                }
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    /// Same as `get` but as an egui texture (created/cached here), with its size in pixels.
135    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        // ponytail: no join — a mid-decode worker exits at its next loop; joining could stall the UI.
173    }
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, // drop decoders → release file handles
207            Job::Decode(key, path, t, h, backend, epoch) => {
208                let p = pool.get_or_insert_with(|| DecoderPool::new(backend));
209                p.set_backend(backend); // no-op when unchanged
210                                        // ponytail: a decoder panic must not kill the only thumb worker — memoise it as a failed
211                                        // entry (so it is never retried) and drop the pool so the next job starts on fresh decoders.
212                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; // cleared while decoding: discard the stale result
223                }
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
236/// Decode `path` at `t` scaled to `h` px tall, aspect kept. None on any failure.
237fn 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
254/// Drop least-recently-used decoded thumbnails beyond CAP entries or BUDGET bytes (failed memos are
255/// exempt: they cost nothing).
256/// ponytail: O(n) min-scan per insert over ≤512 entries on the worker thread — a real LRU list if CAP grows.
257fn 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                // worker finished: either the frame just landed or the failure was memoised
291                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(); // 320x240: red 0–2 s, green 2–4 s
305        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        // repeated calls hit the cache: immediate, and nothing queued
316        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        // textures: created once, stable id, right size
323        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        // clear forgets everything
328        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        // wait until the worker resolved it
339        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        // still None, and never re-queued
350        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        // any t maps to the same cached entry
373        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        // shrink the cap indirectly: fill ready by hand and let evict() trim it
396        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 }); // failed memo: never evicted
401        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        // the oldest ticks went first
405        assert!(st.evicted.contains(&0) && st.evicted.contains(&1) && st.evicted.contains(&2));
406        assert!(st.ready.contains_key(&9999));
407    }
408
409    /// Big thumbnails are bounded by bytes long before the entry count cap is reached.
410    #[test]
411    fn lru_evicts_on_byte_budget() {
412        let mut st = empty_shared();
413        let big = Arc::new(Frame::new(512, 512)); // 1 MiB each
414        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    /// A track-height drag must reuse cached entries instead of queueing a decode per pixel of height.
425    #[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        // MIN_TRACK_H..MAX_TRACK_H in the timeline: 277 pixel heights → 18 distinct decodes
430        let keys: HashSet<u64> = (24..=300).map(|h| key_of("a.mp4", 0.5, qh(h))).collect();
431        assert_eq!(keys.len(), 18);
432        // get() and texture() must agree on the key, else every texture lookup misses
433        assert_eq!(key_of("a.mp4", 0.5, qh(41)), key_of("a.mp4", 0.5, qh(48)));
434    }
435}