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:
parent
68927178e2
commit
d9eaa0fe8f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue