Merge pull request #1210 from arnav-hiwarkar/feat/copy-presets-folder-path

This commit is contained in:
Brandon Thomas 2026-10-13 04:35:21 -04:00
commit acb77a8c85
6 changed files with 150 additions and 1 deletions

View File

@ -37,6 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always), fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always),
fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always),
fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always),
fe!("effects.copyPresetsFolderPath", "Copy Presets Folder Path", [], None, "{}", always),
// Preview. // Preview.
fe!( fe!(
"playback.toggle", "playback.toggle",

View File

@ -227,6 +227,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Edit in the Timeline", "タイムラインで編集"), ("Edit in the Timeline", "タイムラインで編集"),
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
("Effects & Presets options", "エフェクト&プリセットのオプション"), ("Effects & Presets options", "エフェクト&プリセットのオプション"),
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
("Effects: {}", "エフェクト:{}"), ("Effects: {}", "エフェクト:{}"),
("Empty folder", "空のフォルダー"), ("Empty folder", "空のフォルダー"),
("Enable Depth of Field", "被写界深度を有効化"), ("Enable Depth of Field", "被写界深度を有効化"),
@ -770,6 +771,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Edit in the Timeline", "在时间轴中编辑"), ("Edit in the Timeline", "在时间轴中编辑"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
("Effects & Presets options", "效果和预设选项"), ("Effects & Presets options", "效果和预设选项"),
("Copy Presets Folder Path", "复制预设文件夹路径"),
("Effects: {}", "效果:{}"), ("Effects: {}", "效果:{}"),
("Empty folder", "空文件夹"), ("Empty folder", "空文件夹"),
("Enable Depth of Field", "启用景深"), ("Enable Depth of Field", "启用景深"),
@ -1501,6 +1503,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Edit in the Timeline", "在時間軸中編輯"), ("Edit in the Timeline", "在時間軸中編輯"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
("Effects & Presets options", "效果和預設集選項"), ("Effects & Presets options", "效果和預設集選項"),
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
("Effects: {}", "效果:{}"), ("Effects: {}", "效果:{}"),
("Empty folder", "空資料夾"), ("Empty folder", "空資料夾"),
("Enable Depth of Field", "啟用景深"), ("Enable Depth of Field", "啟用景深"),
@ -2235,6 +2238,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Edit in the Timeline", "Редагувати на шкалі часу"), ("Edit in the Timeline", "Редагувати на шкалі часу"),
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
("Effects & Presets options", "Параметри ефектів і наборів"), ("Effects & Presets options", "Параметри ефектів і наборів"),
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
("Effects: {}", "Ефекти: {}"), ("Effects: {}", "Ефекти: {}"),
("Empty folder", "Порожня тека"), ("Empty folder", "Порожня тека"),
("Enable Depth of Field", "Увімкнути глибину різкості"), ("Enable Depth of Field", "Увімкнути глибину різкості"),
@ -2786,6 +2790,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Edit in the Timeline", "Editar na linha do tempo"), ("Edit in the Timeline", "Editar na linha do tempo"),
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
("Effects & Presets options", "Opções de Efeitos e predefinições"), ("Effects & Presets options", "Opções de Efeitos e predefinições"),
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
("Effects: {}", "Efeitos: {}"), ("Effects: {}", "Efeitos: {}"),
("Empty folder", "Pasta vazia"), ("Empty folder", "Pasta vazia"),
("Enable Depth of Field", "Ativar profundidade de campo"), ("Enable Depth of Field", "Ativar profundidade de campo"),
@ -3235,6 +3240,7 @@ const CZECH: &[(&str, &str)] = &[
("Edit in the Timeline", "Upravit na časové ose"), ("Edit in the Timeline", "Upravit na časové ose"),
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
("Effects & Presets options", "Možnosti efektů a přednastavení"), ("Effects & Presets options", "Možnosti efektů a přednastavení"),
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
("Effects: {}", "Efekty: {}"), ("Effects: {}", "Efekty: {}"),
("Empty folder", "Prázdná složka"), ("Empty folder", "Prázdná složka"),
("Enable Depth of Field", "Povolit hloubku ostrosti"), ("Enable Depth of Field", "Povolit hloubku ostrosti"),

View File

@ -291,6 +291,9 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
/// Execute a UI or engine command by id. /// Execute a UI or engine command by id.
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> { pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if id == "effects.copyPresetsFolderPath" {
return frontend(app, ctx, id, params);
}
// Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of
// the comp, as After Effects does, so typing goes into Source Text rather than running // the comp, as After Effects does, so typing goes into Source Text rather than running
// shortcuts (#866). `engine.execute` keeps the headless default. // shortcuts (#866). `engine.execute` keeps the headless default.
@ -877,6 +880,16 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
let now = ctx.input(|i| i.time); let now = ctx.input(|i| i.time);
let v = &mut app.ui.viewer; let v = &mut app.ui.viewer;
Ok(match id { Ok(match id {
"effects.copyPresetsFolderPath" => {
if cfg!(target_arch = "wasm32") {
return Err("The web app has no local presets folder".into());
}
let path = crate::panels::effects_presets::presets_dir().filter(|path| !path.as_os_str().is_empty()).ok_or("No presets folder is configured")?;
let path = std::path::absolute(path).map_err(|e| format!("Cannot resolve the presets folder path: {e}"))?;
let path = path.to_str().ok_or("The presets folder path is not valid UTF-8")?;
ctx.copy_text(path.to_string());
json!({"path": path})
}
"app.about" => { "app.about" => {
app.dialog = Some(crate::Dialog::About); app.dialog = Some(crate::Dialog::About);
Value::Null Value::Null

View File

@ -300,9 +300,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
"-".into(), "-".into(),
" Refresh List".into(), " Refresh List".into(),
" Clear Recently Used".into(), " Clear Recently Used".into(),
"-".into(),
format!(" {}", tr("Copy Presets Folder Path")),
]; ];
let mut force_scan = false; let mut force_scan = false;
if let Some(i) = widgets::popup_menu(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), &menu, None) { let separators = menu.iter().filter(|label| *label == "-").count();
let choice = if widgets::popup_is_open(ui, mpop) {
widgets::popup_list(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), Rect::NOTHING, menu.len().saturating_sub(separators), separators, |ui| {
ui.set_min_width(160.0);
let mut chosen = None;
for (i, label) in menu.iter().enumerate() {
if label == "-" {
ui.separator();
} else {
let resp = ui.selectable_label(false, label);
if i == 12 {
app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path");
}
if resp.clicked() {
chosen = Some(i);
}
}
}
chosen
})
} else {
None
};
if let Some(i) = choice {
let v = &mut app.ui.effects_view; let v = &mut app.ui.effects_view;
match i { match i {
0 => v.depth = "all".into(), 0 => v.depth = "all".into(),
@ -313,6 +338,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
7 => v.alphabetical = true, 7 => v.alphabetical = true,
9 => force_scan = true, 9 => force_scan = true,
10 => app.ui.effects_recent.clear(), 10 => app.ui.effects_recent.clear(),
12 => {
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
app.ui.status = e;
}
}
_ => {} _ => {}
} }
} }

View File

@ -0,0 +1,90 @@
//! Effects & Presets: copy the configured folder path through the contents menu.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
use egui_kittest::Harness;
use serde_json::json;
fn expected_folder() -> String {
let path = std::env::var("EC_PRESETS_DIR").unwrap_or_else(|_| {
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap();
std::path::Path::new(&home).join("Documents/EffectCraft/Presets").to_str().unwrap().to_string()
});
if path.is_empty() { path } else { std::path::absolute(path).unwrap().to_str().unwrap().to_string() }
}
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();
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 {
if let OutputCommand::CopyText(text) = command {
copied.push(text.clone());
}
}
}
copied
}
#[test]
fn presets_menu_copies_the_configured_folder_without_editing_the_project() {
let expected = expected_folder();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
let mut app = EffectcraftApp::new(Session::default());
app.ui.start_screen = false;
app.show_panel(PanelKind::EffectsPresets);
app
});
h.run_steps(3);
let before = serde_json::to_value(&h.state().session.project).unwrap();
let revision = h.state().session.revision;
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
click(&mut h, "effects.menu");
h.run_steps(3);
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
h.render().unwrap().save(path).unwrap();
}
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected]);
h.run_steps(2);
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before);
assert_eq!(h.state().session.revision, revision);
}
#[test]
fn presets_folder_control_command_returns_the_path_or_a_configuration_error() {
let mut app = EffectcraftApp::new(Session::default());
let ctx = egui::Context::default();
let expected = expected_folder();
let mut reply = None;
let mut output = ctx.run_ui(Default::default(), |ui| {
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "effects.copyPresetsFolderPath"}));
reply = match control::handle(&mut app, ui.ctx(), &request) {
control::Outcome::Done(value) => Some(value),
_ => panic!("expected an immediate reply"),
};
});
output.textures_delta.clear();
let copied: Vec<_> = output
.platform_output
.commands
.iter()
.filter_map(|command| match command {
OutputCommand::CopyText(text) => Some(text.as_str()),
_ => None,
})
.collect();
if expected.is_empty() {
assert_eq!(reply, Some(json!({"ok": false, "error": "No presets folder is configured"})));
assert!(copied.is_empty());
assert_eq!(app.ui.status, "No presets folder is configured");
} else {
assert_eq!(reply, Some(json!({"ok": true, "result": {"path": expected}})));
assert_eq!(copied, [expected.as_str()]);
}
}

View File

@ -10,6 +10,15 @@ user can do, an agent can do too. There are three ways in:
| **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no | | **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no |
| **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes | | **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes |
## Find the presets folder
The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies
the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets`
using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may
not exist yet; this action does not create it.
Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies
the same path and returns `{path}`. The web app reports that it has no local presets folder.
## MCP setup ## MCP setup
Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude