perf(project): read file sizes once per sort

This commit is contained in:
arnav-hiwarkar
2026-10-13 15:12:32 +05:30
parent 91ee7bf1b3
commit b63efeb0d3
4 changed files with 276 additions and 2 deletions
+14
View File
@@ -252,6 +252,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Focus Distance", "フォーカス距離"),
("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"),
("Footage: {}", "フッテージ:{}"),
("File size: {} bytes", "ファイルサイズ: {} バイト"),
("File size unavailable", "ファイルサイズを取得できません"),
("Format: {}", "形式:{}"),
("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"),
("Frame Blending: {}", "フレームブレンド:{}"),
@@ -796,6 +798,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Focus Distance", "焦距"),
("Follow Feature Rotation and Scale", "跟随特征的旋转和缩放"),
("Footage: {}", "素材:{}"),
("File size: {} bytes", "文件大小:{} 字节"),
("File size unavailable", "无法获取文件大小"),
("Format: {}", "格式:{}"),
("Frame Aspect Ratio: {}", "帧长宽比:{}"),
("Frame Blending: {}", "帧混合:{}"),
@@ -1528,6 +1532,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Focus Distance", "焦距"),
("Follow Feature Rotation and Scale", "跟隨特徵的旋轉和縮放"),
("Footage: {}", "素材:{}"),
("File size: {} bytes", "檔案大小:{} 位元組"),
("File size unavailable", "無法取得檔案大小"),
("Format: {}", "格式:{}"),
("Frame Aspect Ratio: {}", "影格長寬比:{}"),
("Frame Blending: {}", "影格混合:{}"),
@@ -2263,6 +2269,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Focus Distance", "Відстань фокусування"),
("Follow Feature Rotation and Scale", "Враховувати обертання та масштаб ознаки"),
("Footage: {}", "Матеріал: {}"),
("File size: {} bytes", "Розмір файлу: {} байт"),
("File size unavailable", "Розмір файлу недоступний"),
("Format: {}", "Формат: {}"),
("Frame Aspect Ratio: {}", "Пропорції кадру: {}"),
("Frame Blending: {}", "Змішування кадрів: {}"),
@@ -2815,6 +2823,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Focus Distance", "Distância de foco"),
("Follow Feature Rotation and Scale", "Acompanhar rotação e escala do recurso"),
("Footage: {}", "Mídia: {}"),
("File size: {} bytes", "Tamanho do arquivo: {} bytes"),
("File size unavailable", "Tamanho do arquivo indisponível"),
("Format: {}", "Formato: {}"),
("Frame Aspect Ratio: {}", "Proporção do quadro: {}"),
("Frame Blending: {}", "Mesclagem de quadros: {}"),
@@ -3265,6 +3275,8 @@ const CZECH: &[(&str, &str)] = &[
("Focus Distance", "Vzdálenost zaostření"),
("Follow Feature Rotation and Scale", "Sledovat otočení a měřítko prvku"),
("Footage: {}", "Záznam: {}"),
("File size: {} bytes", "Velikost souboru: {} bajtů"),
("File size unavailable", "Velikost souboru není k dispozici"),
("Format: {}", "Formát: {}"),
("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"),
("Frame Blending: {}", "Prolnutí snímků: {}"),
@@ -3820,6 +3832,8 @@ const SPANISH: &[(&str, &str)] = &[
("Focus Distance", "Distancia de enfoque"),
("Follow Feature Rotation and Scale", "Seguir la rotación y la escala de la característica"),
("Footage: {}", "Material de archivo: {}"),
("File size: {} bytes", "Tamaño del archivo: {} bytes"),
("File size unavailable", "Tamaño del archivo no disponible"),
("Format: {}", "Formato: {}"),
("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"),
("Frame Blending: {}", "Fusión de fotogramas: {}"),
+156 -2
View File
@@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [
/// Sort siblings by the Project panel's sort column; ties fall back to the name.
pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
if col == "size" {
sort_by_size(items, desc, file_size);
return;
}
let name = |i: &Item| i.name.to_lowercase();
items.sort_by(|a, b| {
let o = match col {
"type" => a.type_name().cmp(b.type_name()),
"size" => file_size(a).cmp(&file_size(b)),
"fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal),
"duration" => a.duration().cmp(&b.duration()),
"path" => file_path(a).cmp(file_path(b)),
@@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
});
}
fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option<u64>) {
let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase());
if desc {
items.sort_by_cached_key(|it| std::cmp::Reverse(key(it)));
} else {
items.sort_by_cached_key(key);
}
}
/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root.
pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option<ItemId>) -> Option<ItemId> {
let it = project.item(row?)?;
@@ -754,7 +766,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cp = lp.with_clip_rect(cell);
let txt = match *key {
"type" => it.type_name().to_string(),
"size" => file_size(it).map(fmt_size).unwrap_or_default(),
"size" => {
let size = file_size(it);
let text = size.map(fmt_size).unwrap_or_else(|| if matches!(it.kind, ItemKind::Footage(_)) { "—".into() } else { String::new() });
if matches!(it.kind, ItemKind::Footage(_)) {
let detail = match size {
Some(bytes) => crate::i18n::tr_args("File size: {} bytes", &[&bytes]),
None => crate::i18n::tr("File size unavailable").to_string(),
};
let resp = ui.interact(cell, egui::Id::new(("psize", id.0)), Sense::hover()).on_hover_text(detail.as_str());
app.auto.add_response(&format!("project.item.{}.size", id.0), &resp, &detail);
}
text
}
"duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
"fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
"path" => file_path(it).to_string(),
@@ -1113,6 +1137,136 @@ pub fn set_column(cols: &mut Vec<String>, key: &str, on: bool) {
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::project::{Footage, Project};
struct SizeFiles(std::path::PathBuf);
impl SizeFiles {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn add(&self, project: &mut Project, name: &str, bytes: Option<u64>) {
let path = self.0.join(name);
if let Some(bytes) = bytes {
std::fs::File::create(&path).unwrap().set_len(bytes).unwrap();
}
project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }));
}
}
impl Drop for SizeFiles {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn size_keys_are_sampled_once_with_stable_ties() {
let mut project = Project::default();
for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] {
project.add_item(name, Label::Aqua, None, ItemKind::Folder);
}
for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] {
let mut reads = std::collections::BTreeMap::new();
let mut rows = project.children(None);
sort_by_size(&mut rows, desc, |item| {
*reads.entry(item.id).or_insert(0) += 1;
match item.name.as_str() {
"Zeta" => Some(30),
"alpha" | "ALPHA" => Some(10),
"Zero" => Some(0),
_ => None,
}
});
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), expected);
assert_eq!(reads.len(), 5);
assert!(reads.values().all(|&count| count == 1));
}
}
#[test]
fn size_sort_reads_files_again_after_they_change() {
let files = SizeFiles::new();
let mut project = Project::default();
project.add_item("Folder", Label::Aqua, None, ItemKind::Folder);
files.add(&mut project, "Missing", None);
files.add(&mut project, "Zero", Some(0));
files.add(&mut project, "Small", Some(10));
files.add(&mut project, "Large", Some(20));
let mut rows = project.children(None);
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Small", "Large"]);
sort_items(&mut rows, "size", true);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Large", "Small", "Zero", "Missing", "Folder"]);
std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap();
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Large", "Small"]);
std::fs::remove_file(files.0.join("Large")).unwrap();
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Large", "Missing", "Zero", "Small"]);
}
#[test]
fn size_sort_accepts_empty_and_singleton_lists() {
let mut project = Project::default();
let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder);
for desc in [false, true] {
sort_items(&mut [], "size", desc);
let mut rows = project.children(None);
sort_items(&mut rows, "size", desc);
assert_eq!(rows.iter().map(|item| item.id).collect::<Vec<_>>(), [id]);
}
}
#[test]
#[ignore = "filesystem benchmark; run scripts/bench-project-size-sort.sh"]
fn size_sort_benchmark() {
let files = SizeFiles::new();
let mut project = Project::default();
for i in 0..1000 {
files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1));
}
let original = project.children(None);
for item in &original {
assert!(file_size(item).is_some());
}
let mut before_times = Vec::new();
let mut after_times = Vec::new();
let mut before_reads = 0;
let mut after_reads = 0;
for _ in 0..5 {
let mut before = original.clone();
before_reads = 0;
let start = std::time::Instant::now();
before.sort_by(|a, b| {
before_reads += 2;
file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
before_times.push(start.elapsed());
let mut after = original.clone();
after_reads = 0;
let start = std::time::Instant::now();
sort_by_size(&mut after, false, |item| {
after_reads += 1;
file_size(item)
});
after_times.push(start.elapsed());
assert_eq!(before.iter().map(|item| item.id).collect::<Vec<_>>(), after.iter().map(|item| item.id).collect::<Vec<_>>());
assert_eq!(after_reads, 1000);
assert!(before_reads > after_reads);
}
before_times.sort();
after_times.sort();
println!(
"1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}",
before_times[2], after_times[2]
);
}
#[test]
fn ae_duration_and_size_formats() {
+102
View File
@@ -84,6 +84,108 @@ fn command_clicking_a_folder_twirl_opens_the_folders_inside_it() {
assert!(h.state().ui.project_open_folders.is_empty(), "and close again together");
}
#[test]
fn size_column_sorts_and_explains_sizes_without_swallowing_row_clicks() {
use effectcraft_ui_egui::theme::ThemeKind;
for (name, size, theme) in [
("wide-dark", egui::vec2(1600.0, 1000.0), ThemeKind::Dark),
("compact-dark", egui::vec2(1024.0, 768.0), ThemeKind::Dark),
("compact-light", egui::vec2(1024.0, 768.0), ThemeKind::Light),
] {
check_size_column(name, size, theme);
}
}
fn check_size_column(name: &str, size: egui::Vec2, theme: effectcraft_ui_egui::theme::ThemeKind) {
struct Files(std::path::PathBuf);
impl Drop for Files {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let files = Files(std::env::temp_dir().join(format!(
"effectcraft-size-ui-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
)));
std::fs::create_dir(&files.0).unwrap();
let mut s = Session::default();
s.prefs.general.language = "en".into();
let project = std::sync::Arc::make_mut(&mut s.project);
let folder = project.add_item("Folder", Label::Aqua, None, ItemKind::Folder);
let mut ids = Vec::new();
for (name, bytes) in [("Small", Some(10)), ("Large", Some(20)), ("Unavailable", None), ("Empty", Some(0))] {
let path = files.0.join(name);
if let Some(bytes) = bytes {
std::fs::File::create(&path).unwrap().set_len(bytes).unwrap();
}
ids.push(project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() })));
}
let [small, large, unavailable, empty] = ids.as_slice() else { panic!("fixture items") };
let (small, large, unavailable, empty) = (*small, *large, *unavailable, *empty);
let mut app = EffectcraftApp::new(s);
app.ui.start_screen = false;
app.ui.project_columns = vec!["size".into()];
let mut h = Harness::builder().with_size(size).build_eframe(|_| app);
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, theme);
h.run_steps(3);
let size_id = |id: ItemId| format!("project.item.{}.size", id.0);
let assert_order = |h: &Harness<'_, EffectcraftApp>, expected: &[ItemId]| {
let mut visible = expected.to_vec();
visible.sort_by(|a, b| center(h, &format!("project.item.{}", a.0)).y.total_cmp(&center(h, &format!("project.item.{}", b.0)).y));
assert_eq!(visible, expected);
};
let undo = h.state().session.history.undo.len();
click(&mut h, "project.sort.size");
assert_order(&h, &[folder, unavailable, empty, small, large]);
click(&mut h, "project.sort.size");
assert_order(&h, &[large, small, empty, unavailable, folder]);
assert_eq!(h.state().auto.find(&size_id(empty)).unwrap().label, "File size: 0 bytes");
assert!(h.state().auto.find(&size_id(folder)).is_none());
let at = center(&h, &size_id(small));
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(5);
assert!(h.query_by_label("File size: 10 bytes").is_some(), "hover exposes exact bytes when both files display 1 KB");
if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") {
h.render().unwrap().save(format!("{dir}/project-size-{name}-hover.png")).unwrap();
}
click_at(&mut h, at);
assert_eq!(h.state().session.state.project_selection, [small]);
let at = center(&h, &size_id(unavailable));
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(5);
assert!(h.query_by_label("File size unavailable").is_some());
let has_dash = |shape: &egui::Shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == "—");
assert!(h.output().shapes.iter().any(|shape| has_dash(&shape.shape)), "unavailable footage has a visible marker");
if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") {
h.render().unwrap().save(format!("{dir}/project-size-{name}-unavailable.png")).unwrap();
}
std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap();
h.run_steps(3);
assert_order(&h, &[small, large, empty, unavailable, folder]);
assert_eq!(h.state().auto.find(&size_id(small)).unwrap().label, "File size: 30 bytes");
assert_eq!(h.state().session.history.undo.len(), undo, "sorting and size details don't edit the project");
let from = center(&h, &size_id(small));
let to = center(&h, &format!("project.item.{}.name", folder.0));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
button(&mut h, from, true);
h.step();
for k in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0)));
h.step();
}
button(&mut h, to, false);
h.run_steps(3);
assert_eq!(h.state().session.project.item(small).unwrap().parent, Some(folder), "dragging from Size still moves the item");
assert_eq!(h.state().session.history.undo.len(), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().session.project.item(small).unwrap().parent, None);
assert_order(&h, &[small, large, empty, unavailable, folder]);
}
#[test]
fn folder_twirl_opens_and_closes_the_folder_on_one_click() {
let (mut h, folder, comp, _) = harness();
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
CARGO_PROFILE_RELEASE_LTO=false cargo test -p effectcraft-ui-egui --release --lib panels::project::tests::size_sort_benchmark -- --ignored --nocapture