mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
perf(presets): reuse cached text and user preset data
This commit is contained in:
@@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
@@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query};
|
||||
/// The original text animator presets (`presets/text_animators.json`).
|
||||
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
fn presets() -> &'static [Value] {
|
||||
static PRESETS: OnceLock<Vec<Value>> = OnceLock::new();
|
||||
PRESETS
|
||||
.get_or_init(|| serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// (id, name) of every preset.
|
||||
@@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
||||
let preset = presets()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
||||
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
||||
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
||||
|
||||
@@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() {
|
||||
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_text_presets_keep_the_catalog_and_application_independent() {
|
||||
let expected = [
|
||||
("typewriter", "Typewriter"),
|
||||
("fadeUpCharacters", "Fade Up Characters"),
|
||||
("bounceInWords", "Bounce In Words"),
|
||||
("trackingIn", "Tracking In"),
|
||||
("scramble", "Scramble"),
|
||||
("blurIn", "Blur In"),
|
||||
("jitter", "Jitter"),
|
||||
("dropInLines", "Drop In Lines"),
|
||||
];
|
||||
let (mut s, layer) = session();
|
||||
let catalog = s.execute("text.presets", json!({})).unwrap();
|
||||
let mut returned = catalog.clone();
|
||||
returned[0]["name"] = json!("Edited copy");
|
||||
let project = s.project.clone();
|
||||
for _ in 0..2 {
|
||||
let names = crate::text_presets();
|
||||
assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::<Vec<_>>(), expected);
|
||||
for (id, _) in expected {
|
||||
let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap();
|
||||
assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project, project, "{id}: undo restores the document");
|
||||
}
|
||||
assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm text-preset listing benchmark"]
|
||||
fn text_preset_listing_benchmark() {
|
||||
let old = || {
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap();
|
||||
let presets = v["presets"].as_array().unwrap().clone();
|
||||
presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(old(), crate::text_presets());
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(old());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(crate::text_presets());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_have_text_animation_entries() {
|
||||
let (s, _) = session();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::i18n::tr;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::DragPayload;
|
||||
@@ -228,7 +229,7 @@ fn preset_row(
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
on_apply: Apply,
|
||||
on_apply: impl FnOnce() -> Apply,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
@@ -245,22 +246,22 @@ fn preset_row(
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(on_apply);
|
||||
*apply = Some(on_apply());
|
||||
}
|
||||
}
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
|
||||
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
|
||||
let id = egui::Id::new("fx-user-presets");
|
||||
let now = ctx.input(|i| i.time);
|
||||
if !force
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
|
||||
&& now - at < 5.0
|
||||
{
|
||||
return (*v).clone();
|
||||
return v;
|
||||
}
|
||||
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
|
||||
let v = Arc::new(scan());
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -389,13 +390,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
categories.push(c);
|
||||
}
|
||||
}
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
|
||||
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
@@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
@@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
Apply::PresetFile(f.path.clone()),
|
||||
|| Apply::PresetFile(f.path.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
@@ -536,6 +537,64 @@ mod tests {
|
||||
let v = scan_presets(&d);
|
||||
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
|
||||
assert!(scan_presets(&d.join("missing")).is_empty());
|
||||
let ctx = egui::Context::default();
|
||||
let set_time = |time| {
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |_| {});
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
let scans = std::cell::Cell::new(0);
|
||||
let scan = || {
|
||||
scans.set(scans.get() + 1);
|
||||
scan_presets(&d)
|
||||
};
|
||||
set_time(1.0);
|
||||
let first = user_presets(&ctx, false, scan);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("bounce.ECPRESET")).unwrap();
|
||||
std::fs::write(d.join("Another.ecpreset"), "{}").unwrap();
|
||||
set_time(5.999);
|
||||
let hit = user_presets(&ctx, false, scan);
|
||||
assert!(Arc::ptr_eq(&first, &hit));
|
||||
assert_eq!(hit.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
assert_eq!(scans.get(), 1);
|
||||
set_time(6.0);
|
||||
let expired = user_presets(&ctx, false, scan);
|
||||
assert_eq!(expired.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Another", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("Another.ecpreset")).unwrap();
|
||||
let refreshed = user_presets(&ctx, true, scan);
|
||||
assert_eq!(refreshed.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Zoom In"]);
|
||||
assert_eq!(scans.get(), 3);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm custom-preset cache benchmark"]
|
||||
fn user_preset_cache_benchmark() {
|
||||
let ctx = egui::Context::default();
|
||||
let files = user_presets(&ctx, false, || {
|
||||
(0..1000).map(|i| PresetFile { name: format!("Preset {i:04}"), path: format!("/presets/Preset {i:04}.ecpreset") }).collect()
|
||||
});
|
||||
assert_eq!(files.first().unwrap().name, "Preset 0000");
|
||||
assert_eq!(files.last().unwrap().name, "Preset 0999");
|
||||
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 (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
std::hint::black_box((*shared).clone());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(user_presets(&ctx, false, Vec::new));
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&files, &user_presets(&ctx, false, Vec::new)));
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 custom presets, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Effects & Presets: copy the configured folder path through the contents menu.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::panels::effects_presets::PresetFile;
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
|
||||
use egui_kittest::Harness;
|
||||
@@ -18,8 +19,9 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
|
||||
let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
let mut copied = Vec::new();
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for command in &h.output().platform_output.commands {
|
||||
@@ -88,3 +90,74 @@ fn presets_folder_control_command_returns_the_path_or_a_configuration_error() {
|
||||
assert_eq!(copied, [expected.as_str()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
|
||||
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!(
|
||||
"ec-preset-rows-{}-{}",
|
||||
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 entries = Vec::new();
|
||||
for (name, value) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
|
||||
let path = files.0.join(format!("{name}.ecpreset"));
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": name, "properties": [{"path": "transform/opacity", "value": {"t": "Scalar", "v": value}}], "effects": []});
|
||||
std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap();
|
||||
entries.push(PresetFile { name: name.into(), path: path.to_string_lossy().into_owned() });
|
||||
}
|
||||
let mut session = Session::default();
|
||||
session.prefs.general.language = "en".into();
|
||||
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
session.execute("layer.newText", json!({"text": "Cache"})).unwrap();
|
||||
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::EffectsPresets);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let shared = std::sync::Arc::new(entries);
|
||||
let now = h.ctx.input(|i| i.time);
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-user-presets"), (now, shared.clone())));
|
||||
let search = |h: &mut Harness<'_, EffectcraftApp>, text: &str| {
|
||||
click(h, "effects.search");
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(40);
|
||||
};
|
||||
search(&mut h, "Typewriter");
|
||||
assert!(h.state().auto.find("effects.preset.text.typewriter").is_some());
|
||||
assert!(h.state().auto.find("effects.preset.file.Custom Cache A").is_none());
|
||||
click(&mut h, "effects.preset.text.typewriter");
|
||||
click(&mut h, "effects.preset.text.typewriter");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().groups().count(), 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
for (name, expected) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
|
||||
search(&mut h, name);
|
||||
let id = format!("effects.preset.file.{name}");
|
||||
let history = h.state().session.history.undo.len();
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
|
||||
click(&mut h, &id);
|
||||
click(&mut h, &id);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected);
|
||||
assert_eq!(h.state().session.history.undo.len(), history + 1);
|
||||
if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
}
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
|
||||
}
|
||||
let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&shared, &cached));
|
||||
assert_eq!(shared.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Custom Cache A", "Custom Cache B"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user