mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
perf(project): read file sizes once per sort
This commit is contained in:
@@ -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: {}"),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(¢er(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();
|
||||
|
||||
Executable
+4
@@ -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
|
||||
Reference in New Issue
Block a user