M5.7: effect presets — save (scale / anchor to in / anchor to out / without keyframes), apply (undoable; intrinsics replaced, geometry scaled to the target frame), built-in presets, user library in the data dir, JSON import/export; Presets bin in the Effects panel (drag onto clips), Save Preset dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:44:55 -04:00
parent 68927178e2
commit d9eaa0fe8f
12 changed files with 1003 additions and 3 deletions

View File

@ -1884,6 +1884,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::proxies::commands());
v.extend(crate::project_manager::commands());
v.extend(crate::masks::commands());
v.extend(crate::presets::commands());
// Labels as individual commands (Edit ▸ Label ▸ <name>)
for l in Label::ALL {
let _ = l;

View File

@ -19,6 +19,7 @@ pub mod interchange;
pub mod masks;
pub mod media_pool;
pub mod mixer;
pub mod presets;
pub mod previews;
pub mod project_manager;
pub mod proxies;
@ -219,6 +220,8 @@ pub struct Session {
pub media_jobs: Vec<proxies::PendingJob>,
/// Mask tracking jobs whose keyframes are still being written.
pub mask_jobs: Vec<masks::PendingTrack>,
/// Effect presets (built-in + the user's, persisted in the data directory).
pub presets: presets::PresetLibrary,
/// Nesting depth of [`Session::execute`] (commands that run other commands).
exec_depth: u32,
}
@ -286,6 +289,7 @@ impl Session {
offline: Default::default(),
media_jobs: Vec::new(),
mask_jobs: Vec::new(),
presets: Default::default(),
exec_depth: 0,
}
}
@ -298,6 +302,7 @@ impl Session {
self.prefs = autosave::Preferences::load(&prefs_path);
self.media.set_use_proxies(self.prefs.media.enable_proxies);
self.shortcuts.set_dir(&cfg.data_dir);
self.presets.set_dir(&cfg.data_dir);
self.prefs_path = Some(prefs_path);
self.persistence = Some(autosave::Persistence::start(&cfg, self.prefs.auto_save.clone())?);
self.sync_persistence();
@ -642,6 +647,8 @@ mod media_test_util;
#[cfg(test)]
mod mixer_tests;
#[cfg(test)]
mod presets_tests;
#[cfg(test)]
mod previews_tests;
#[cfg(test)]
mod project_manager_tests;

View File

@ -0,0 +1,554 @@
//! Effect presets (Premiere's Presets bin): one or more configured effects — parameters,
//! keyframes and masks — saved under a name and applied to clips.
//!
//! - **Keyframes.** Times are stored relative to the in point of the clip the preset was saved
//! from, with that clip's length (`source_duration`, media ticks). Applying re-times them per
//! [`KeyframeMode`]: *Scale* stretches them over the target clip, *Anchor to In Point* keeps their
//! distance from the target's in point, *Anchor to Out Point* their distance from its out point.
//! Saving with `keyframes: "none"` stores every parameter as its value at the playhead.
//! - **Geometry.** Point parameters and mask paths are in clip pixels; they are scaled by the
//! target / source frame-size ratio so a preset made on 4K media lands in the same place on HD.
//! - **Intrinsic effects** (Motion, Opacity, Time Remapping) replace the target clip's own instance;
//! standard effects are added before the intrinsic ones (render order), like `effects.apply`.
//! - **Library.** Built-in presets are authored in code ([`builtin_presets`]); user presets persist
//! in `<data dir>/effect-presets.json`. Preset files (export/import) are JSON:
//! `{"format": "filmcraft.effect-presets", "version": 1, "presets": [EffectPreset…]}`.
//!
//! Commands: `presets.list`, `presets.save`, `presets.apply` (undoable), `presets.delete`,
//! `presets.rename`, `presets.export`, `presets.import`.
use std::path::{Path, PathBuf};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::{ClipId, EffectInstance, Keyframe, Mask, MaskPath, Param, ParamValue, TrackItem, TrackKind};
use filmcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, clip_p, has_seq, str_p};
use crate::{EngineError, Result, Session};
pub const FILE_FORMAT: &str = "filmcraft.effect-presets";
const LIBRARY_FILE: &str = "effect-presets.json";
/// How a preset's keyframes are placed on the target clip.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyframeMode {
#[default]
Scale,
AnchorToIn,
AnchorToOut,
}
impl KeyframeMode {
pub fn from_name(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"scale" => Some(KeyframeMode::Scale),
"anchorin" | "anchortoin" | "anchortoinpoint" | "in" => Some(KeyframeMode::AnchorToIn),
"anchorout" | "anchortoout" | "anchortooutpoint" | "out" => Some(KeyframeMode::AnchorToOut),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
KeyframeMode::Scale => "Scale",
KeyframeMode::AnchorToIn => "Anchor to In Point",
KeyframeMode::AnchorToOut => "Anchor to Out Point",
}
}
}
/// A saved effect preset.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EffectPreset {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default)]
pub keyframes: KeyframeMode,
/// Length (media ticks) of the clip the preset was saved from; keyframe times are relative to its in point.
pub source_duration: Tick,
/// Frame size (clip pixels) of the clip the preset was saved from.
#[serde(default = "hd")]
pub source_size: (u32, u32),
pub effects: Vec<EffectInstance>,
/// Built-in (not saved, cannot be deleted).
#[serde(skip)]
pub builtin: bool,
}
fn hd() -> (u32, u32) {
(1920, 1080)
}
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
version: u32,
presets: Vec<EffectPreset>,
}
/// User presets (+ where they persist).
#[derive(Default)]
pub struct PresetLibrary {
pub user: Vec<EffectPreset>,
dir: Option<PathBuf>,
}
impl PresetLibrary {
/// Persist user presets in `data_dir` (and load what is there).
pub fn set_dir(&mut self, data_dir: &Path) {
self.dir = Some(data_dir.to_path_buf());
let path = data_dir.join(LIBRARY_FILE);
if let Ok(bytes) = std::fs::read(&path) {
match parse_file(&bytes) {
Ok(p) => self.user = p,
Err(e) => log::warn!("{}: {e}", path.display()),
}
}
}
fn persist(&self) -> std::result::Result<(), String> {
let Some(d) = &self.dir else { return Ok(()) };
let _ = std::fs::create_dir_all(d);
let bytes = file_bytes(&self.user);
filmcraft_format::atomic_write(&d.join(LIBRARY_FILE), &bytes).map_err(|e| e.to_string())
}
/// Built-in and user presets (user presets shadow built-ins of the same name).
pub fn all(&self) -> Vec<EffectPreset> {
let mut v: Vec<EffectPreset> = builtin_presets().into_iter().filter(|b| !self.user.iter().any(|u| u.name == b.name)).collect();
v.extend(self.user.iter().cloned());
v
}
pub fn find(&self, name: &str) -> Option<EffectPreset> {
self.user.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
}
}
fn file_bytes(presets: &[EffectPreset]) -> Vec<u8> {
let f = PresetFile { format: FILE_FORMAT.into(), version: 1, presets: presets.to_vec() };
serde_json::to_vec_pretty(&f).unwrap_or_default()
}
fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<EffectPreset>, String> {
let f: PresetFile = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
if f.format != FILE_FORMAT {
return Err(format!("not an effect preset file (format `{}`)", f.format));
}
if f.version > 1 {
return Err(format!("preset file version {} is newer than this build reads (1)", f.version));
}
for p in &f.presets {
if p.effects.iter().any(|e| e.def().is_none()) {
return Err(format!("preset `{}` uses an effect this build doesn't have", p.name));
}
}
Ok(f.presets)
}
// ---------------------------------------------------------------- built-ins
fn kf(t_secs: f64, v: ParamValue) -> Keyframe {
Keyframe::new(Tick((t_secs * TICKS_PER_SECOND as f64).round() as i64), v)
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {
let mut e = filmcraft_project::find_effect(id).unwrap_or_else(|| panic!("built-in preset uses unknown effect {id}")).instance();
for (k, v) in params {
if let Some(p) = e.params.get_mut(*k) {
p.value = v.clone();
}
}
e
}
fn animate(e: &mut EffectInstance, param: &str, keys: Vec<Keyframe>) {
if let Some(p) = e.params.get_mut(param) {
p.value = keys[0].value.clone();
p.keyframes = keys;
}
}
fn preset(name: &str, description: &str, keyframes: KeyframeMode, secs: f64, effects: Vec<EffectInstance>) -> EffectPreset {
EffectPreset {
name: name.into(),
description: description.into(),
keyframes,
source_duration: Tick((secs * TICKS_PER_SECOND as f64) as i64),
source_size: hd(),
effects,
builtin: true,
}
}
/// Presets that ship with FilmCraft (original work: parameter values chosen by us).
pub fn builtin_presets() -> Vec<EffectPreset> {
let f = ParamValue::Float;
let mut blur_in = effect("gaussian_blur", &[]);
animate(&mut blur_in, "blurriness", vec![kf(0.0, f(60.0)), kf(1.0, f(0.0))]);
let mut blur_out = effect("gaussian_blur", &[]);
animate(&mut blur_out, "blurriness", vec![kf(9.0, f(0.0)), kf(10.0, f(60.0))]);
let mut fade_in = effect("opacity", &[]);
animate(&mut fade_in, "opacity", vec![kf(0.0, f(0.0)), kf(1.0, f(100.0))]);
let mut fade_out = effect("opacity", &[]);
animate(&mut fade_out, "opacity", vec![kf(9.0, f(100.0)), kf(10.0, f(0.0))]);
let mut mosaic_in = effect("mosaic", &[]);
animate(&mut mosaic_in, "horizontal", vec![kf(0.0, f(8.0)), kf(1.0, f(400.0))]);
animate(&mut mosaic_in, "vertical", vec![kf(0.0, f(5.0)), kf(1.0, f(225.0))]);
// soft oval vignette: darken outside an inverted, heavily feathered ellipse
let mut vignette = effect("brightness_contrast", &[("brightness", f(-60.0))]);
let mut m = Mask::new("Mask (1)", MaskPath::ellipse(Vec2::new(960.0, 540.0), Vec2::new(900.0, 520.0)));
m.inverted = true;
m.feather.value = f(320.0);
vignette.masks.push(m);
let mut spotlight = effect("brightness_contrast", &[("brightness", f(18.0)), ("contrast", f(10.0))]);
let mut m = Mask::new("Mask (1)", MaskPath::ellipse(Vec2::new(960.0, 540.0), Vec2::new(420.0, 420.0)));
m.feather.value = f(160.0);
spotlight.masks.push(m);
let warm =
effect("tint", &[("black", ParamValue::Color([0.08, 0.04, 0.02, 1.0])), ("white", ParamValue::Color([1.0, 0.93, 0.8, 1.0])), ("amount", f(35.0))]);
let mut pip = effect("motion", &[("scale", f(35.0)), ("position", ParamValue::Vec2(Vec2::new(1530.0, 270.0)))]);
pip.enabled = true;
vec![
preset("Blur In", "Gaussian Blur from 60 to 0 over the first second", KeyframeMode::AnchorToIn, 10.0, vec![blur_in]),
preset("Blur Out", "Gaussian Blur from 0 to 60 over the last second", KeyframeMode::AnchorToOut, 10.0, vec![blur_out]),
preset("Fade In", "Opacity 0 → 100 % over the first second", KeyframeMode::AnchorToIn, 10.0, vec![fade_in]),
preset("Fade Out", "Opacity 100 → 0 % over the last second", KeyframeMode::AnchorToOut, 10.0, vec![fade_out]),
preset("Mosaic In", "Large blocks resolving to the picture over the first second", KeyframeMode::AnchorToIn, 10.0, vec![mosaic_in]),
preset("Soft Vignette", "Darkens outside a feathered oval mask", KeyframeMode::Scale, 10.0, vec![vignette]),
preset("Spotlight", "Brightens inside a feathered circular mask", KeyframeMode::Scale, 10.0, vec![spotlight]),
preset("Warm Tint", "Warm highlights, brown shadows", KeyframeMode::Scale, 10.0, vec![warm]),
preset("PiP 35% Upper Right", "Picture-in-picture: 35 % scale in the upper right", KeyframeMode::Scale, 10.0, vec![pip]),
]
}
// ---------------------------------------------------------------- save / apply
/// Every parameter of an effect instance (its own and its masks').
fn params_mut(e: &mut EffectInstance) -> Vec<&mut Param> {
let mut v: Vec<&mut Param> = e.params.values_mut().collect();
for m in &mut e.masks {
v.extend(m.params_mut());
}
v
}
/// Media length of a track item.
fn media_len(it: &TrackItem) -> Tick {
Tick((it.duration.0 as f64 * it.speed.abs()).round() as i64)
}
/// Build a preset from effects of a clip. `mt` = the clip's media time at the playhead.
pub fn capture(it: &TrackItem, idx: &[usize], name: &str, description: &str, mode: Option<KeyframeMode>, mt: Tick, size: (u32, u32)) -> EffectPreset {
let mut effects: Vec<EffectInstance> = idx.iter().filter_map(|i| it.effects.get(*i).cloned()).collect();
for e in &mut effects {
e.essential = false;
for p in params_mut(e) {
match mode {
None => {
// without keyframes: the value at the playhead
p.value = p.value_at(mt);
p.keyframes.clear();
}
Some(_) => {
for k in &mut p.keyframes {
k.time -= it.source_in;
}
}
}
}
}
EffectPreset {
name: name.to_string(),
description: description.to_string(),
keyframes: mode.unwrap_or_default(),
source_duration: media_len(it),
source_size: size,
effects,
builtin: false,
}
}
/// Map a preset keyframe time (relative to the source in point) onto `target`.
pub fn retime(rel: Tick, mode: KeyframeMode, source_len: Tick, target: &TrackItem) -> Tick {
let tin = target.source_in;
let tlen = media_len(target);
match mode {
KeyframeMode::AnchorToIn => tin + rel,
KeyframeMode::AnchorToOut => tin + tlen - (source_len - rel),
KeyframeMode::Scale => {
if source_len.0 <= 0 {
tin + rel
} else {
tin + Tick((rel.0 as i128 * tlen.0 as i128 / source_len.0 as i128) as i64)
}
}
}
}
fn scale_value(v: &mut ParamValue, sx: f64, sy: f64) {
match v {
ParamValue::Vec2(p) if !p.x.is_nan() => *p = Vec2::new(p.x * sx, p.y * sy),
ParamValue::Path(path) => *path = path.transformed(&Affine::scale(sx, sy)),
_ => {}
}
}
/// The preset's effects prepared for `target` (keyframes re-timed, geometry scaled).
pub fn instantiate(p: &EffectPreset, target: &TrackItem, size: (u32, u32)) -> Vec<EffectInstance> {
let sx = size.0 as f64 / p.source_size.0.max(1) as f64;
let sy = size.1 as f64 / p.source_size.1.max(1) as f64;
let geometric = (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9;
let mut out = p.effects.clone();
for e in &mut out {
// Point params scale only when they are positions (the def kind says so)
let point_ids: Vec<String> = e
.def()
.map(|d| d.params.iter().filter(|q| matches!(q.kind, filmcraft_project::ParamKind::Point)).map(|q| q.id.to_string()).collect())
.unwrap_or_default();
for (id, prm) in e.params.iter_mut() {
for k in &mut prm.keyframes {
k.time = retime(k.time, p.keyframes, p.source_duration, target);
}
prm.keyframes.sort_by_key(|k| k.time);
prm.keyframes.dedup_by_key(|k| k.time);
if geometric && point_ids.contains(id) {
scale_value(&mut prm.value, sx, sy);
for k in &mut prm.keyframes {
scale_value(&mut k.value, sx, sy);
}
}
}
for m in &mut e.masks {
for prm in m.params_mut() {
for k in &mut prm.keyframes {
k.time = retime(k.time, p.keyframes, p.source_duration, target);
}
prm.keyframes.sort_by_key(|k| k.time);
prm.keyframes.dedup_by_key(|k| k.time);
if geometric {
scale_value(&mut prm.value, sx, sy);
for k in &mut prm.keyframes {
scale_value(&mut k.value, sx, sy);
}
}
}
if geometric {
// feather / expansion are lengths: scale by the mean factor
let s = (sx * sy).sqrt();
for prm in [&mut m.feather, &mut m.expansion] {
if let ParamValue::Float(v) = &mut prm.value {
*v *= s;
}
for k in &mut prm.keyframes {
if let ParamValue::Float(v) = &mut k.value {
*v *= s;
}
}
}
}
}
}
out
}
/// Put preset effects on a clip: intrinsic ones replace the clip's instance, others are inserted
/// before the intrinsic effects.
pub fn put_effects(it: &mut TrackItem, effects: Vec<EffectInstance>) {
for e in effects {
let intrinsic = e.def().is_some_and(|d| d.intrinsic);
if intrinsic && let Some(slot) = it.effects.iter_mut().find(|x| x.effect == e.effect) {
*slot = e;
continue;
}
let audio = e.def().is_some_and(|d| d.kind == filmcraft_project::EffectKind::Audio);
let pos = if audio { it.effects.len() } else { it.effects.iter().position(|x| x.def().is_some_and(|d| d.intrinsic)).unwrap_or(it.effects.len()) };
it.effects.insert(pos, e);
}
}
// ---------------------------------------------------------------- commands
type Run = fn(&mut Session, &Value) -> Result<Value>;
fn spec(
id: &'static str,
label: &'static str,
params: &'static str,
enabled: fn(&Session) -> std::result::Result<(), String>,
run: Run,
journal: bool,
) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled, run, journal }
}
pub(crate) fn commands() -> Vec<CommandSpec> {
vec![
spec("presets.list", "List Effect Presets", "{}", always, list, false),
spec(
"presets.save",
"Save Preset",
r#"{"clip":id?,"effects":[index]?,"name":str,"description":str?,"keyframes":"scale"|"anchorIn"|"anchorOut"|"none"?}"#,
has_seq,
save,
true,
),
spec("presets.apply", "Apply Preset", r#"{"preset":str,"clips":[id]?}"#, has_seq, apply, true),
spec("presets.delete", "Delete Preset", r#"{"name":str}"#, always, delete, true),
spec("presets.rename", "Rename Preset", r#"{"name":str,"to":str}"#, always, rename, true),
spec("presets.export", "Export Presets", r#"{"path":str,"names":[str]?}"#, always, export, false),
spec("presets.import", "Import Presets", r#"{"path":str}"#, always, import, true),
]
}
fn preset_json(p: &EffectPreset) -> Value {
json!({
"name": p.name,
"description": p.description,
"builtin": p.builtin,
"keyframes": p.keyframes.label(),
"effects": p.effects.iter().map(|e| e.def().map(|d| d.name).unwrap_or(e.effect.as_str()).to_string()).collect::<Vec<_>>(),
"animated": p.effects.iter().any(|e| e.is_animated()),
"masks": p.effects.iter().map(|e| e.masks.len()).sum::<usize>(),
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"presets": s.presets.all().iter().map(preset_json).collect::<Vec<_>>()}))
}
fn clip_size(s: &Session, it: &TrackItem) -> (u32, u32) {
filmcraft_render::source_size(&s.project, it.item).or_else(|| s.active_sequence().map(|q| (q.settings.width, q.settings.height))).unwrap_or(hd())
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("presets.save", "need a `name`"))?.to_string();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let clip = clip_p(p, "clip")
.or_else(|| s.state.selection.iter().copied().find(|c| seq.find_item(*c).and_then(|(t, _)| seq.track(t)).is_some_and(|t| t.kind == TrackKind::Video)))
.or_else(|| s.state.selection.first().copied())
.ok_or_else(|| bad("presets.save", "select a clip"))?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("presets.save", "no such clip"))?;
let idx: Vec<usize> = match p.get("effects").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(|n| n as usize)).filter(|i| *i < it.effects.len()).collect(),
// default: the standard (non-intrinsic, non-layer) effects
None => (0..it.effects.len())
.filter(|i| {
let e = &it.effects[*i];
e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e) && !e.essential
})
.collect(),
};
if idx.is_empty() {
return Err(bad("presets.save", "no effects to save (pass `effects`)"));
}
let mode = match str_p(p, "keyframes") {
None => Some(KeyframeMode::Scale),
Some(k) if k.eq_ignore_ascii_case("none") => None,
Some(k) => Some(KeyframeMode::from_name(k).ok_or_else(|| bad("presets.save", format!("unknown keyframe mode `{k}`")))?),
};
let mt = it.source_time_at(s.playhead().clamp(it.start, it.end() - Tick(1)));
let size = clip_size(s, it);
let preset = capture(it, &idx, &name, str_p(p, "description").unwrap_or(""), mode, mt, size);
let out = preset_json(&preset);
s.presets.user.retain(|x| x.name != name);
s.presets.user.push(preset);
s.presets.persist().map_err(EngineError::Other)?;
Ok(out)
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "preset").or_else(|| str_p(p, "name")).ok_or_else(|| bad("presets.apply", "need `preset`"))?;
let preset = s.presets.find(name).ok_or_else(|| bad("presets.apply", format!("no preset `{name}`")))?;
let clips: Vec<ClipId> = match p.get("clips").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(),
None => s.state.selection.clone(),
};
if clips.is_empty() {
return Err(bad("presets.apply", "select clips first"));
}
let audio = preset.effects.iter().all(|e| e.def().is_some_and(|d| d.kind == filmcraft_project::EffectKind::Audio));
let sizes: Vec<(ClipId, (u32, u32))> = {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
clips.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| (*c, clip_size(s, it)))).collect()
};
let label = format!("Apply Preset {}", preset.name);
let n = s.edit_sequence(&label, |q, _, _| {
let mut n = 0;
for t in q.all_tracks_mut() {
if (t.kind == TrackKind::Audio) != audio {
continue;
}
for it in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
let size = sizes.iter().find(|(c, _)| *c == it.id).map(|x| x.1).unwrap_or(hd());
let fx = instantiate(&preset, it, size);
put_effects(it, fx);
n += 1;
}
}
if n == 0 {
return Err(bad("presets.apply", "the preset doesn't fit the selected clips (video/audio)"));
}
Ok(n)
})?;
Ok(json!({"applied": n}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").ok_or_else(|| bad("presets.delete", "need `name`"))?;
let before = s.presets.user.len();
s.presets.user.retain(|x| x.name != name);
if s.presets.user.len() == before {
return Err(bad(
"presets.delete",
if builtin_presets().iter().any(|b| b.name == name) { "built-in presets cannot be deleted" } else { "no such preset" },
));
}
s.presets.persist().map_err(EngineError::Other)?;
Ok(Value::Null)
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").ok_or_else(|| bad("presets.rename", "need `name`"))?;
let to = str_p(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("presets.rename", "need `to`"))?.to_string();
if s.presets.user.iter().any(|x| x.name == to) {
return Err(bad("presets.rename", format!("a preset named `{to}` exists")));
}
let pr = s.presets.user.iter_mut().find(|x| x.name == name).ok_or_else(|| bad("presets.rename", "no such user preset"))?;
pr.name = to;
s.presets.persist().map_err(EngineError::Other)?;
Ok(Value::Null)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("presets.export", "need `path`"))?;
let all = s.presets.all();
let chosen: Vec<EffectPreset> = match p.get("names").and_then(Value::as_array) {
Some(n) => {
let names: Vec<&str> = n.iter().filter_map(Value::as_str).collect();
let v: Vec<EffectPreset> = all.into_iter().filter(|x| names.contains(&x.name.as_str())).collect();
if v.len() != names.len() {
return Err(bad("presets.export", "unknown preset name"));
}
v
}
None => s.presets.user.clone(),
};
if chosen.is_empty() {
return Err(bad("presets.export", "no presets to export"));
}
filmcraft_format::atomic_write(Path::new(path), &file_bytes(&chosen)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "count": chosen.len()}))
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("presets.import", "need `path`"))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let presets = parse_file(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let names: Vec<String> = presets.iter().map(|x| x.name.clone()).collect();
for pr in presets {
s.presets.user.retain(|x| x.name != pr.name);
s.presets.user.push(pr);
}
s.presets.persist().map_err(EngineError::Other)?;
Ok(json!({"imported": names}))
}

View File

@ -0,0 +1,205 @@
use serde_json::json;
use crate::Session;
use crate::presets::{KeyframeMode, retime};
use filmcraft_project::{ClipId, ParamValue, TrackItem};
use filmcraft_time::{TICKS_PER_SECOND, Tick};
fn demo() -> (Session, Vec<ClipId>) {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let clips: Vec<ClipId> = q.video_tracks[0].items.iter().map(|i| i.id).collect();
(s, clips)
}
fn item(s: &Session, c: ClipId) -> TrackItem {
s.active_sequence().unwrap().find_item(c).unwrap().1.clone()
}
fn secs(x: f64) -> Tick {
Tick((x * TICKS_PER_SECOND as f64).round() as i64)
}
fn media_len(it: &TrackItem) -> Tick {
Tick((it.duration.0 as f64 * it.speed.abs()).round() as i64)
}
/// Save a keyframed blur from clip A, apply with each keyframe mode to clip B (another length).
#[test]
fn keyframe_modes_retime_onto_the_target() {
let (mut s, clips) = demo();
let (a, b) = (clips[0], clips[2]);
let ia = item(&s, a);
s.state.selection = vec![a];
s.execute("effects.apply", json!({"effect": "gaussian_blur"})).unwrap();
// keyframes 0.5 s after the in point (20) and 1 s before the out point (0)
let k1 = ia.start + secs(0.5);
let k2 = ia.end() - secs(1.0);
s.set_playhead(k1);
let k1 = s.playhead();
s.execute("effects.toggleAnimation", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "time": k1.0})).unwrap();
s.execute("effects.setParam", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "value": 20.0, "time": k1.0})).unwrap();
s.execute("effects.setParam", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "value": 0.0, "time": k2.0})).unwrap();
let ib = item(&s, b);
assert_ne!(media_len(&ia), media_len(&ib), "the demo clips differ in length");
let la = media_len(&ia);
let lb = media_len(&ib);
for (mode, key) in [(KeyframeMode::AnchorToIn, "anchorIn"), (KeyframeMode::AnchorToOut, "anchorOut"), (KeyframeMode::Scale, "scale")] {
let name = format!("blur-{key}");
let bi = item(&s, a).effects.iter().position(|e| e.effect == "gaussian_blur").unwrap();
s.execute("presets.save", json!({"clip": a.0, "name": name, "keyframes": key, "effects": [bi]})).unwrap();
let before = item(&s, b).effects.len();
s.execute("presets.apply", json!({"preset": name, "clips": [b.0]})).unwrap();
let it = item(&s, b);
assert_eq!(it.effects.len(), before + 1);
let e = it.effects.iter().rfind(|e| e.effect == "gaussian_blur").unwrap();
// standard effects go before the intrinsic ones
assert!(it.effects.iter().position(|x| x.effect == "gaussian_blur").unwrap() < it.effects.iter().position(|x| x.effect == "motion").unwrap());
let p = e.param("blurriness").unwrap();
assert_eq!(
p.keyframes.len(),
2,
"{:?} src {:?}",
p.keyframes.iter().map(|k| (k.time, k.value.clone())).collect::<Vec<_>>(),
item(&s, a)
.effects
.iter()
.find(|e| e.effect == "gaussian_blur")
.unwrap()
.param("blurriness")
.unwrap()
.keyframes
.iter()
.map(|k| k.time)
.collect::<Vec<_>>()
);
let rel1 = ia.source_time_at(k1) - ia.source_in;
let rel2 = ia.source_time_at(k2) - ia.source_in;
let (e1, e2) = match mode {
KeyframeMode::AnchorToIn => (ib.source_in + rel1, ib.source_in + rel2),
KeyframeMode::AnchorToOut => (ib.source_in + lb - (la - rel1), ib.source_in + lb - (la - rel2)),
KeyframeMode::Scale => (
ib.source_in + Tick((rel1.0 as i128 * lb.0 as i128 / la.0 as i128) as i64),
ib.source_in + Tick((rel2.0 as i128 * lb.0 as i128 / la.0 as i128) as i64),
),
};
assert_eq!(p.keyframes[0].time, e1, "{key}");
assert_eq!(p.keyframes[1].time, e2, "{key}");
assert_eq!(p.keyframes[0].value, ParamValue::Float(20.0));
assert_eq!(retime(rel1, mode, la, &ib), e1);
s.undo();
assert_eq!(item(&s, b).effects.len(), before, "apply is one undo step");
}
}
#[test]
fn save_without_keyframes_keeps_the_value_at_the_playhead() {
let (mut s, clips) = demo();
let a = clips[0];
let ia = item(&s, a);
s.state.selection = vec![a];
s.execute("effects.apply", json!({"effect": "gaussian_blur"})).unwrap();
s.set_playhead(ia.start);
s.execute("effects.toggleAnimation", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "time": ia.start.0})).unwrap();
s.execute("effects.setParam", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "value": 40.0, "time": ia.start.0})).unwrap();
s.execute("effects.setParam", json!({"clip": a.0, "effect": "gaussian_blur", "param": "blurriness", "value": 0.0, "time": (ia.start + secs(2.0)).0}))
.unwrap();
s.set_playhead(ia.start + secs(1.0));
let bi = item(&s, a).effects.iter().position(|e| e.effect == "gaussian_blur").unwrap();
s.execute("presets.save", json!({"name": "half blur", "keyframes": "none", "effects": [bi]})).unwrap();
let l = s.execute("presets.list", json!({})).unwrap();
let p = l["presets"].as_array().unwrap().iter().find(|p| p["name"] == "half blur").unwrap().clone();
assert_eq!(p["animated"], false);
s.execute("presets.apply", json!({"preset": "half blur", "clips": [clips[1].0]})).unwrap();
let e = item(&s, clips[1]).effects.into_iter().find(|e| e.effect == "gaussian_blur").unwrap();
let v = e.param("blurriness").unwrap();
assert!(!v.is_animated());
let ParamValue::Float(x) = v.value else { panic!() };
assert!((x - 20.0).abs() < 1.0, "value at the playhead: {x}");
}
#[test]
fn presets_with_masks_and_intrinsics_round_trip_through_files() {
let (mut s, clips) = demo();
let a = clips[0];
s.state.selection = vec![a];
s.execute("effects.apply", json!({"effect": "black_white"})).unwrap();
s.execute("masks.add", json!({"effect": "black_white", "shape": "ellipse", "center": [960, 540], "size": [800, 400]})).unwrap();
s.execute("effects.setParam", json!({"clip": a.0, "effect": "opacity", "param": "opacity", "value": 50.0})).unwrap();
let ia = item(&s, a);
let bw = ia.effects.iter().position(|e| e.effect == "black_white").unwrap();
let op = ia.effects.iter().position(|e| e.effect == "opacity").unwrap();
s.execute("presets.save", json!({"name": "masked bw", "effects": [bw, op], "description": "test"})).unwrap();
let dir = std::env::temp_dir().join(format!("filmcraft-presets-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("p.json").to_string_lossy().to_string();
let r = s.execute("presets.export", json!({"path": path, "names": ["masked bw", "Fade In"]})).unwrap();
assert_eq!(r["count"], 2);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("filmcraft.effect-presets"));
// a fresh session imports and applies it
let (mut s2, clips2) = demo();
s2.execute("presets.import", json!({"path": path})).unwrap();
let names: Vec<String> =
s2.execute("presets.list", json!({})).unwrap()["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect();
assert!(names.contains(&"masked bw".to_string()));
s2.execute("presets.apply", json!({"preset": "masked bw", "clips": [clips2[1].0]})).unwrap();
let it = item(&s2, clips2[1]);
let e = it.effects.iter().find(|e| e.effect == "black_white").unwrap();
assert_eq!(e.masks.len(), 1);
assert_eq!(it.effects.iter().filter(|e| e.effect == "opacity").count(), 1, "intrinsic replaced, not duplicated");
assert_eq!(it.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(50.0));
// garbage is rejected
std::fs::write(dir.join("bad.json"), b"{\"format\":\"something\",\"version\":1,\"presets\":[]}").unwrap();
assert!(s2.execute("presets.import", json!({"path": dir.join("bad.json").to_string_lossy()})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builtins_apply_and_user_presets_persist() {
let (mut s, clips) = demo();
let l = s.execute("presets.list", json!({})).unwrap();
let all = l["presets"].as_array().unwrap();
assert!(all.len() >= 8);
assert!(all.iter().all(|p| p["builtin"] == true));
for p in all {
let name = p["name"].as_str().unwrap();
s.execute("presets.apply", json!({"preset": name, "clips": [clips[0].0]})).unwrap_or_else(|e| panic!("{name}: {e}"));
}
// Fade Out anchors to the out point
let it = item(&s, clips[0]);
let op = it.effect("opacity").unwrap().param("opacity").unwrap();
assert_eq!(op.keyframes.last().unwrap().time, it.source_in + media_len(&it));
assert!(s.execute("presets.delete", json!({"name": "Fade In"})).is_err(), "built-ins stay");
// persistence through the data directory
let dir = std::env::temp_dir().join(format!("filmcraft-presets-lib-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
s.presets.set_dir(&dir);
s.state.selection = vec![clips[1]];
s.execute("effects.apply", json!({"effect": "invert"})).unwrap();
s.execute("presets.save", json!({"name": "Mine"})).unwrap();
s.execute("presets.rename", json!({"name": "Mine", "to": "Mine 2"})).unwrap();
let mut lib = crate::presets::PresetLibrary::default();
lib.set_dir(&dir);
assert_eq!(lib.user.len(), 1);
assert_eq!(lib.user[0].name, "Mine 2");
s.execute("presets.delete", json!({"name": "Mine 2"})).unwrap();
let mut lib = crate::presets::PresetLibrary::default();
lib.set_dir(&dir);
assert!(lib.user.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mask_geometry_scales_to_the_target_frame_size() {
let (s, clips) = demo();
let it = item(&s, clips[0]);
let p = crate::presets::builtin_presets().into_iter().find(|p| p.name == "Spotlight").unwrap();
let fx = crate::presets::instantiate(&p, &it, (960, 540));
let m = &fx[0].masks[0];
let path = m.path_at(it.source_in);
let c = path.centroid();
assert!((c.x - 480.0).abs() < 1e-6 && (c.y - 270.0).abs() < 1e-6, "{c:?}");
assert_eq!(m.feather.value, ParamValue::Float(80.0));
}

View File

@ -6,6 +6,7 @@ use crate::{Dialog, FilmcraftApp};
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::media_dialogs::show(app, ctx);
crate::panels::color_dialogs::show(app, ctx);
crate::panels::presets::save_dialog(app, ctx);
let Some(d) = app.dialog else { return };
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
if !still_open && app.dialog == Some(d) {

View File

@ -121,12 +121,23 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.collapsed_fx.retain(|k| *k != key);
}
}
let mut save_preset = false;
let fx_id = e.effect.clone();
resp.context_menu(|ui| {
let sp = ui.button("Save Preset…");
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
if sp.clicked() {
save_preset = true;
ui.close();
}
if !def.intrinsic && ui.button("Clear").clicked() {
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
ui.close();
}
});
if save_preset {
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
}
if !open {
continue;
}

View File

@ -23,8 +23,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// folder tree: top-level categories in Premiere's order
let tops = ["Presets", "Lumetri Presets", "Audio Effects", "Audio Transitions", "Video Effects", "Video Transitions"];
let mut apply: Option<String> = None;
let mut preset_action: Option<(String, String)> = None;
egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| {
for top in tops {
if top == "Presets" {
let any = filter.is_empty() || app.session.presets.all().iter().any(|p| p.name.to_ascii_lowercase().contains(&filter));
if any && folder_row(app, ui, top, 0, &filter) {
preset_action = crate::panels::presets::folder_rows(app, ui, &filter).or(preset_action.take());
}
continue;
}
let items: Vec<_> = defs
.iter()
.filter(|d| d.category.first() == Some(&top))
@ -85,6 +93,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
});
if let Some((cmd, name)) = preset_action {
crate::panels::presets::run(app, &cmd, &name);
}
if let Some(id) = apply {
let r = app.session.execute("effects.apply", json!({"effect": id}));
if let Err(e) = r {

View File

@ -17,6 +17,7 @@ pub mod meters;
pub mod misc;
pub mod mixer;
pub mod monitor;
pub mod presets;
pub mod project;
pub mod shortcuts_dialog;
pub mod text;
@ -106,7 +107,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
if let Some(p) = ctx.pointer_hover_pos() {
let label = match &pl {
DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(),
DragPayload::Effect(e) => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(),
DragPayload::Effect(e) => match e.strip_prefix("preset:") {
Some(name) => name.to_string(),
None => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(),
},
};
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost")));
let r = Rect::from_min_size(p + egui::vec2(12.0, 8.0), egui::vec2(label.len() as f32 * 7.0 + 16.0, 20.0));

View File

@ -0,0 +1,133 @@
//! Effect presets in the UI: the Presets bin of the Effects panel (built-in and user presets; drag
//! onto a clip or double-click to apply to the selection; right-click a user preset to delete or
//! export it) and the Save Preset dialog (Effect Controls ▸ right-click an effect ▸ Save Preset…).
//!
//! Automation ids: `effects.preset.<name>`, `savePreset.name`, `savePreset.description`,
//! `savePreset.keyframes.<scale|anchorIn|anchorOut|none>`, `savePreset.ok`, `savePreset.cancel`.
use egui::{Align2, Rect, Sense, pos2, vec2};
use serde_json::json;
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::SavePresetDraft;
use crate::theme::Tokens;
/// The rows of the Presets folder (inside the Effects panel's scroll area).
pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Option<(String, String)> {
let t = app.tokens;
let mut action = None;
for p in app.session.presets.all() {
if !filter.is_empty() && !p.name.to_ascii_lowercase().contains(filter) {
continue;
}
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click_and_drag());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 30.0;
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x, r.center().y), vec2(13.0, 13.0)),
Icon::Sparkle,
if p.builtin { t.text_dim } else { t.accent },
);
ui.painter().text(pos2(x + 12.0, r.center().y), Align2::LEFT_CENTER, &p.name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.preset.{}", p.name), r, &p.name);
let tip = if p.description.is_empty() { p.name.clone() } else { p.description.clone() };
let resp = resp.on_hover_text(tip);
if resp.drag_started() {
crate::panels::start_drag_effect(ui, &format!("preset:{}", p.name));
}
if resp.double_clicked() {
action = Some(("presets.apply".to_string(), p.name.clone()));
}
resp.context_menu(|ui| {
if !p.builtin && ui.button("Delete Preset").clicked() {
action = Some(("presets.delete".to_string(), p.name.clone()));
ui.close();
}
if ui.button("Export Preset…").clicked() {
action = Some(("presets.export".to_string(), p.name.clone()));
ui.close();
}
if ui.button("Import Presets…").clicked() {
action = Some(("presets.import".to_string(), String::new()));
ui.close();
}
});
}
action
}
/// Run a Presets-bin action.
pub fn run(app: &mut FilmcraftApp, cmd: &str, name: &str) {
let r = match cmd {
"presets.apply" => app.session.execute(cmd, json!({"preset": name})),
"presets.delete" => app.session.execute(cmd, json!({"name": name})),
"presets.export" => {
let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f("Effect presets", &["json"], &format!("{name}.json"))) else { return };
app.session.execute(cmd, json!({"path": path, "names": [name]}))
}
"presets.import" => {
let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Effect presets", &["json"])) else { return };
app.session.execute(cmd, json!({"path": path}))
}
_ => return,
};
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
/// Open the Save Preset dialog for effects of a clip.
pub fn open_save(app: &mut FilmcraftApp, clip: u64, effects: Vec<usize>, name: &str) {
app.ui.save_preset = Some(SavePresetDraft { clip, effects, name: name.to_string(), description: String::new(), keyframes: "scale".into() });
}
pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.save_preset.clone() else { return };
let mut open = true;
let mut close = false;
let mut apply = false;
egui::Window::new("Save Preset").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Name:");
let r = ui.text_edit_singleline(&mut d.name);
app.auto.add("savePreset.name", r.rect, "Name");
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Type").strong());
for (key, label) in [("scale", "Scale"), ("anchorIn", "Anchor to In Point"), ("anchorOut", "Anchor to Out Point"), ("none", "Without keyframes")] {
let r = ui.radio(d.keyframes == key, label);
app.auto.add(&format!("savePreset.keyframes.{key}"), r.rect, label);
if r.clicked() {
d.keyframes = key.into();
}
}
ui.add_space(4.0);
ui.label("Description:");
let r = ui.text_edit_multiline(&mut d.description);
app.auto.add("savePreset.description", r.rect, "Description");
ui.add_space(6.0);
ui.horizontal(|ui| {
let c = ui.button("Cancel");
app.auto.add("savePreset.cancel", c.rect, "Cancel");
let o = ui.add_enabled(!d.name.trim().is_empty(), egui::Button::new("OK"));
app.auto.add("savePreset.ok", o.rect, "OK");
apply = o.clicked();
close = apply || c.clicked();
});
});
app.ui.save_preset = if close || !open { None } else { Some(d.clone()) };
if apply {
let r = app.session.execute(
"presets.save",
json!({"clip": d.clip, "effects": d.effects, "name": d.name.trim(), "description": d.description, "keyframes": d.keyframes}),
);
match r {
Ok(_) => app.ui.status = format!("Saved preset \u{201c}{}\u{201d}", d.name.trim()),
Err(e) => app.ui.status = e.to_string(),
}
}
}

View File

@ -1583,7 +1583,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
if ctx.input(|i| i.pointer.any_released()) {
let is_transition = filmcraft_project::find_effect(&effect)
.is_some_and(|d| matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition));
let r = if is_transition {
let r = if let Some(name) = effect.strip_prefix("preset:") {
app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]}))
} else if is_transition {
let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" };
app.session.execute("effects.apply", json!({"effect": effect, "clip": clip.0, "edge": edge}))
} else {

View File

@ -1,6 +1,6 @@
//! Effect masks in the UI, driven headless through the control channel: create masks from the
//! Effect Controls mask icons, edit them on the Program monitor (vertex, Bézier handle, feather,
//! whole-mask drags), draw a pen mask, toggle Inverted.
//! whole-mask drags), draw a pen mask, toggle Inverted, track a mask; apply and save effect presets.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes PNGs there.
@ -215,3 +215,50 @@ fn create_and_edit_masks_on_the_monitor() {
assert_eq!(pen["pathKeyframes"].as_array().unwrap().len(), 2, "{pen}");
d.shot("mask-tracked");
}
#[test]
fn presets_bin_drag_and_save_dialog() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Effects"}));
let seq = d.exec("sequence.inspect", json!({}));
let clip = &seq["video"][0]["items"][0];
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
d.exec("playhead.set", json!({"time": start + dur / 2}));
d.exec("timeline.select", json!({"clips": [id]}));
// Presets bin: open the folder, double-click a built-in preset
d.ok("ui.click", json!({"id": "effects.folder.Presets"}));
d.frames(2);
d.rect("effects.preset.Soft Vignette");
// drag the preset onto the clip in the timeline
let (fx0, fy0) = d.center("effects.preset.Soft Vignette");
let (cx, cy) = d.center(&format!("timeline.clip.{id}"));
d.ok("ui.drag", json!({"from": {"x": fx0, "y": fy0}, "to": {"x": cx, "y": cy}, "steps": 10}));
d.frames(3);
let seq = d.exec("sequence.inspect", json!({}));
let fx = seq["video"][0]["items"][0]["effects"].as_array().unwrap().clone();
let bc = fx.iter().find(|e| e["effect"] == "brightness_contrast").unwrap_or_else(|| panic!("{fx:?}"));
assert_eq!(bc["masks"], 1);
d.shot("presets-bin");
// Save Preset dialog from the effect's context menu (opened through the command-equivalent UI state)
for fx in ["motion", "opacity", "time_remap"] {
d.ok("ui.click", json!({"id": format!("effectControls.effect.{fx}")}));
d.frames(2);
}
d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast", "button": "right"}));
d.frames(2);
d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast.savePreset"}));
d.frames(2);
d.rect("savePreset.name");
d.ok("ui.click", json!({"id": "savePreset.name"}));
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.ok("ui.type", json!({"text": "My Vignette"}));
d.ok("ui.click", json!({"id": "savePreset.keyframes.anchorIn"}));
d.shot("save-preset-dialog");
d.ok("ui.click", json!({"id": "savePreset.ok"}));
d.frames(3);
let l = d.exec("presets.list", json!({}));
let mine = l["presets"].as_array().unwrap().iter().find(|p| p["name"] == "My Vignette").cloned().expect("saved");
assert_eq!(mine["keyframes"], "Anchor to In Point");
assert_eq!(mine["masks"], 1);
d.rect("effects.preset.My Vignette");
}

View File

@ -322,3 +322,27 @@ new paths and fingerprints, so it opens with no missing media.
Dialog ids: `pm.seq.<id>`, `pm.mode.<collect|consolidate>`, `pm.preset.<id>`, `pm.excludeUnused`,
`pm.handles`, `pm.includeProxies`, `pm.includePreviews`, `pm.destination`, `pm.browse`,
`pm.calculate`, `pm.sizes`, `pm.ok`, `pm.cancel`.
## Effect presets
Effect presets (Effects panel ▸ Presets; Effect Controls ▸ right-click an effect ▸ Save Preset…) are
not part of the project. Built-in presets are defined in code (`filmcraft_engine::presets`); user
presets live in `<data dir>/effect-presets.json`, and `presets.export` / `presets.import` read and
write the same JSON format:
```json
{"format": "filmcraft.effect-presets", "version": 1, "presets": [
{"name": "My Vignette", "description": "", "keyframes": "AnchorToIn",
"source_duration": 2540160000000, "source_size": [1920, 1080],
"effects": [ /* EffectInstance, exactly as in .fcproj: effect, enabled, params, masks */ ]}
]}
```
Keyframe times are relative to the in point of the clip the preset was saved from
(`source_duration` is that clip's media length in ticks). Applying re-times them: `Scale` stretches
them over the target clip, `AnchorToIn` / `AnchorToOut` keep their distance from the target's in /
out point. Point parameters, mask paths, feather and expansion are scaled by the target / source
frame-size ratio. Intrinsic effects (Motion, Opacity…) replace the clip's own instance; other
effects are added. Applying is one undo step; saving, renaming, deleting and importing change the
library, not the project, and are not undoable. Files with another `format`, a newer `version` or
unknown effect ids are refused.