From bb1a0e5209c847fc0650394593d7463c5dcebedb Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 17:42:07 +0530 Subject: [PATCH] perf(media-browser): reuse listings and cache filename sort keys --- crates/engine/src/media_browser.rs | 67 +++++++- crates/ui-egui/src/panels/media_panels.rs | 59 ++++++- .../ui-egui/tests/ui_media_browser_cache.rs | 145 ++++++++++++++++++ 3 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/ui_media_browser_cache.rs diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 5edd67ca..a77859d5 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -173,7 +173,7 @@ impl VirtualTree { }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } } @@ -193,6 +193,10 @@ pub struct Entry { pub kind: Option<&'static str>, } +fn sort_entries(entries: &mut [Entry]) { + entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); +} + const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; @@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result, String> { Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } @@ -407,6 +411,10 @@ mod tests { assert_eq!(media.len(), 3); assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str())); assert!(list(&dir.join("nope").to_string_lossy(), false).is_err()); + std::fs::write(dir.join("Ω.csv"), b"x").unwrap(); + std::fs::write(dir.join("作品.csv"), b"x").unwrap(); + let all = list(&d, false).unwrap(); + assert_eq!(all.iter().map(|e| e.name.as_str()).collect::>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]); std::fs::remove_dir_all(&dir).unwrap(); } @@ -436,6 +444,61 @@ mod tests { assert_eq!(vparent("/"), None); } + #[test] + fn listings_keep_folders_first_and_stable_unicode_order() { + let mut tree = VirtualTree::default(); + for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] { + tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None); + } + for name in ["zFolder", "Alpha"] { + tree.add_dir(&format!("/Shoot/{name}")); + } + let entries = tree.list("/Shoot", true).unwrap(); + assert_eq!( + entries.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"] + ); + assert_eq!(entries[2].path, "/files/A.csv"); + let mut reversed = entries.iter().rev().cloned().collect::>(); + sort_entries(&mut reversed); + assert_eq!( + reversed.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"] + ); + } + + #[test] + #[ignore = "Media Browser filename sorting benchmark"] + fn directory_sort_benchmark() { + let entries: Vec<_> = (0..1000) + .map(|i| Entry { + name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000), + path: format!("/Shoot/{i}.mov"), + is_dir: i % 7 == 0, + size: 1, + modified: None, + kind: Some("video"), + }) + .collect(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let mut old = entries.clone(); + let start = std::time::Instant::now(); + old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + before.push(start.elapsed()); + let mut new = entries.clone(); + let start = std::time::Instant::now(); + sort_entries(&mut new); + after.push(start.elapsed()); + assert_eq!(old, new); + std::hint::black_box(new); + } + before.sort(); + after.sort(); + println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]); + } + #[test] fn iso_dates() { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index 4a0ab6ae..2023fcd5 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -15,6 +15,7 @@ use effectcraft_engine::media_browser as mb; use effectcraft_engine::project::ItemId; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; +use std::sync::Arc; use super::DragPayload; use super::panel_kit as kit; @@ -24,6 +25,7 @@ use crate::{EffectcraftApp, widgets}; // ---------------------------------------------------------------- Media Browser type Thumb = Option<(egui::TextureHandle, [u32; 2])>; +type Listing = (f64, Arc, String>>); /// Thumbnail of a file (cached per path; `None` while not decoded or not decodable). fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb { @@ -76,11 +78,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Listing cached per folder for a second. let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only)); let now = ctx.input(|i| i.time); - let listing: Option<(f64, Result, String>)> = ctx.data(|d| d.get_temp(lid)); + let listing: Option = ctx.data(|d| d.get_temp(lid)); let entries = match listing { Some((at, l)) if now - at < 1.0 => l, _ => { - let l = b.list(&dir, st.importable_only); + let l = Arc::new(b.list(&dir, st.importable_only)); ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone()))); l } @@ -115,7 +117,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) { kit::exec(app, "mediaBrowser.action", json!({"action": id})); - ctx.data_mut(|d| d.remove::<(f64, Result, String>)>(lid)); + ctx.data_mut(|d| d.remove::(lid)); } ax -= 6.0; } @@ -150,7 +152,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Files. let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max); - let entries = match entries { + let entries = match entries.as_ref() { Ok(e) => e, Err(e) => { p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger); @@ -409,3 +411,52 @@ pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f } full.max.y - 16.0 - bottom } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "Media Browser warm listing cache benchmark"] + fn listing_cache_benchmark() { + let ctx = egui::Context::default(); + let entries: Vec<_> = (0..1000) + .map(|i| mb::Entry { + name: format!("Clip {i:04}.mov"), + path: format!("/Shoot/作品 Clip {i:04}.mov"), + is_dir: false, + size: 1, + modified: None, + kind: Some("video"), + }) + .collect(); + let old_id = egui::Id::new("old-list"); + let new_id = egui::Id::new("new-list"); + ctx.data_mut(|d| { + d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone()))); + d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries)))); + }); + let (_, original) = ctx.data(|d| d.get_temp::(new_id)).unwrap(); + assert_eq!(original.as_ref().as_ref().unwrap()[0].name, "Clip 0000.mov"); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = std::hint::black_box(ctx.data(|d| d.get_temp::<(f64, Result, String>)>(old_id)).unwrap()); + } + before.push(start.elapsed()); + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(ctx.data(|d| d.get_temp::(new_id)).unwrap()); + } + after.push(start.elapsed()); + } + let (_, cached) = ctx.data(|d| d.get_temp::(new_id)).unwrap(); + assert!(Arc::ptr_eq(&original, &cached)); + assert_eq!(cached.as_ref().as_ref().unwrap().last().unwrap().name, "Clip 0999.mov"); + before.sort(); + after.sort(); + println!("1,000 Media Browser entries, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000); + } +} diff --git a/crates/ui-egui/tests/ui_media_browser_cache.rs b/crates/ui-egui/tests/ui_media_browser_cache.rs new file mode 100644 index 00000000..feade97b --- /dev/null +++ b/crates/ui-egui/tests/ui_media_browser_cache.rs @@ -0,0 +1,145 @@ +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use effectcraft_engine::{ + Session, + media_browser::{Browser, Entry, VirtualTree}, +}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +#[derive(Default)] +struct MutableBrowser { + tree: Mutex, + offline: AtomicBool, +} + +impl Browser for MutableBrowser { + fn home(&self) -> String { + "/Shoot".into() + } + fn list(&self, dir: &str, only: bool) -> Result, String> { + if self.offline.load(Ordering::Relaxed) { + return Err("Folder temporarily unavailable".into()); + } + self.tree.lock().unwrap().list(dir, only) + } + fn actions(&self) -> Vec<(String, String)> { + vec![("refresh".into(), "Refresh".into())] + } + fn action(&self, id: &str) -> Result { + assert_eq!(id, "refresh"); + self.tree.lock().unwrap().add_file("/Shoot/From action.csv", "/files/action.csv", 1, None); + Ok(json!({})) + } +} + +type Listing = (f64, Arc, String>>); + +fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing { + let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into()); + h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap() +} + +fn names(list: &Arc, String>>) -> Vec<&str> { + list.as_ref().as_ref().unwrap().iter().map(|e| e.name.as_str()).collect() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + let pos = pos2(e.rect[0] + 8.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn at(h: &mut Harness<'_, EffectcraftApp>, time: f64) { + h.input_mut().time = Some(time); + h.step(); + h.input_mut().time = Some(time); + h.step(); + h.input_mut().time = None; +} + +#[test] +fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() { + let browser = Arc::new(MutableBrowser::default()); + { + let mut tree = browser.tree.lock().unwrap(); + tree.add_file("/Shoot/Clip.csv", "/files/clip.csv", 1, None); + tree.add_file("/Shoot/notes.txt", "/files/notes.txt", 1, None); + tree.add_file("/Shoot/Sub/Child.csv", "/files/child.csv", 1, None); + } + let mut session = Session { browser: Some(browser.clone()), ..Default::default() }; + session.prefs.general.language = "en".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| { + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = false; + app.show_panel(PanelKind::MediaBrowser); + app.ui.maximized = Some(PanelKind::MediaBrowser); + app + }); + h.run_steps(3); + let (time, first) = listing(&h, false); + assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + browser.tree.lock().unwrap().add_file("/Shoot/Added.csv", "/files/added.csv", 2, Some(5)); + at(&mut h, time + 0.999); + assert!(Arc::ptr_eq(&first, &listing(&h, false).1)); + assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_none()); + at(&mut h, time + 1.0); + let refreshed = listing(&h, false).1; + assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_some()); + assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]); + click(&mut h, "mediaBrowser.importableOnly"); + assert!(h.state().session.state.media_browser.importable_only); + assert_eq!(names(&listing(&h, true).1), ["Sub", "Added.csv", "Clip.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.notes.txt").is_none()); + click(&mut h, "mediaBrowser.importableOnly"); + assert!(!h.state().session.state.media_browser.importable_only); + assert!(Arc::ptr_eq(&refreshed, &listing(&h, false).1)); + click(&mut h, "mediaBrowser.action.refresh"); + assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.From action.csv").is_some()); + assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]); + h.run_steps(40); + click(&mut h, "mediaBrowser.entry.Sub"); + click(&mut h, "mediaBrowser.entry.Sub"); + assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot/Sub")); + assert_eq!(names(&listing(&h, false).1), ["Child.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_some()); + browser.tree.lock().unwrap().remove("/Shoot/Sub/Child.csv"); + let time = listing(&h, false).0; + at(&mut h, time + 1.0); + assert_eq!(names(&listing(&h, false).1), Vec::<&str>::new()); + assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_none()); + click(&mut h, "mediaBrowser.up"); + assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot")); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + browser.offline.store(true, Ordering::Relaxed); + let time = listing(&h, false).0; + at(&mut h, time + 1.0); + let (time, error) = listing(&h, false); + assert_eq!(error.as_ref(), &Err("Folder temporarily unavailable".into())); + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Folder temporarily unavailable"))); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_none()); + browser.offline.store(false, Ordering::Relaxed); + browser.tree.lock().unwrap().remove("/Shoot/notes.txt"); + at(&mut h, time + 0.999); + assert!(Arc::ptr_eq(&error, &listing(&h, false).1)); + at(&mut h, time + 1.0); + assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } +}