diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1058f4c54..47b3cbde9 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -156,6 +156,7 @@ mod enabled; mod params; mod schema; +use super::*; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; @@ -280,3 +281,14 @@ pub fn find(id: &str) -> Option<&'static CommandSpec> { }); index.get(id).map(|&i| &command_specs()[i]) } + +/// Font families available to text layers (bundled + scanned system fonts). +/// Text animation presets: (id, name), for menus. +pub fn text_presets() -> Vec<(String, String)> { + commands::text_preset_list() +} + +/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category). +pub fn animation_presets() -> Vec<(String, String, String)> { + commands::builtin_preset_list() +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs new file mode 100644 index 000000000..e5fb1791b --- /dev/null +++ b/crates/engine/src/doc_state.rs @@ -0,0 +1,197 @@ +use super::*; + +/// A keyframe reference: layer, property uid, key time (layer time). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct KeyRef { + pub layer: LayerId, + pub prop: Uid, + pub time: Tick, +} + +/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it +/// started from and the total offset so far, so every step is applied to the keys as they were +/// before the drag. A key passed over on the way is never overwritten; only the drop position +/// counts, as in After Effects. +#[derive(Clone, Debug)] +pub struct KeyMove { + pub merge: String, + pub from: Vec, + pub total: Tick, +} + +/// Keyframes of one property on the keyframe clipboard. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KeyClip { + pub layer: LayerId, + /// Match-id path relative to the layer (`transform/position`), portable between layers. + pub path: String, + /// Keys with times relative to the earliest copied key (layer time). + pub keys: Vec, +} + +/// A selected mask vertex: layer, mask group uid, vertex index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VertexRef { + pub layer: LayerId, + pub mask: Uid, + pub index: usize, +} + +/// One composition's selection. The active comp's copy lives in [`EditorState::selected_layers`] +/// (and the property, key, and vertex lists); every other comp keeps its own here (#1001). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CompSelection { + pub layers: Vec, + pub props: Vec<(LayerId, Uid)>, + pub keys: Vec, + pub vertices: Vec, +} + +impl CompSelection { + pub(crate) fn is_empty(&self) -> bool { + self.layers.is_empty() && self.props.is_empty() && self.keys.is_empty() && self.vertices.is_empty() + } +} + +/// Editing state that commands depend on (headless-relevant, serde for agents). +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct EditorState { + pub active_comp: Option, + /// Open compositions (viewer/timeline tabs), in tab order. + pub open_comps: Vec, + /// Compositions that were active before the current one, most recent first (Shift+Esc). + #[serde(default)] + pub recent_comps: Vec, + /// Current time per comp (the CTI). + pub times: std::collections::BTreeMap, + /// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`]. + pub selected_layers: Vec, + /// Selections of comps that are not active, keyed by comp id (#1001). + #[serde(default)] + pub comp_selections: std::collections::BTreeMap, + /// Selected properties / groups (by uid) of selected layers. + pub selected_props: Vec<(LayerId, Uid)>, + pub selected_keys: Vec, + pub project_selection: Vec, + /// Layer clipboard (serialized layers) and keyframe clipboard. + #[serde(skip)] + pub clipboard: Vec, + /// Copied keyframes (times relative to the earliest copied key). + #[serde(skip)] + pub key_clipboard: Vec, + /// The last copy was keyframes (Paste pastes keys at the CTI). + #[serde(skip)] + pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// Copied effect instances, with comp-time key offsets from the earliest copied key. + /// Paste adds them to the selected layers at the destination comp's CTI. + #[serde(skip)] + pub effect_clipboard: Vec, + /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in + /// a shape layer's Contents); Paste adds them to the selected shape layers. + #[serde(skip)] + pub contents_clipboard: Vec, + /// Selected mask vertices (viewer Selection tool / pen). + #[serde(default)] + pub selected_vertices: Vec, + pub snapping: bool, + /// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended). + #[serde(default)] + pub snap_features: viewer::SnapFeatures, + /// Last applied effect id (Effect ▸ last effect). + pub last_effect: Option, + /// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region + /// of Interest). + #[serde(default)] + pub region_of_interest: Option<[f64; 4]>, + /// Property-link clipboard (Edit ▸ Copy with Property Links / Copy Expression Only). + #[serde(skip)] + pub link_clipboard: Option, + /// File ▸ Interpret Footage ▸ Remember Interpretation. + #[serde(skip)] + pub interpretation: Option, + /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). + #[serde(default)] + pub views3d: std::collections::BTreeMap, + /// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp. + #[serde(default)] + pub current_track: Option<(LayerId, Uid)>, + /// Tracker panel ▸ Method in mask mode (Track Mask). + #[serde(default)] + pub mask_track_method: mask_track::MaskMethod, + /// 3D Camera Tracker: the selected track points (ids) in the viewer. + #[serde(default)] + pub camera_points: Vec, + /// Mask Interpolation panel options. + #[serde(default)] + pub mask_interp: commands::mask_interp::MaskInterpOptions, + /// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools). + #[serde(default)] + pub paint: commands::paint::PaintOptions, + /// Roto Brush and Refine Edge tool options. + #[serde(default)] + pub roto: roto::RotoOptions, + /// Puppet tool options for new meshes. + #[serde(default)] + pub puppet: commands::puppet::PuppetOptions, + /// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke). + #[serde(default)] + pub shape_tool: commands::shape_tool::ShapeTool, + /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. + #[serde(default = "one_view")] + pub view_layout: u8, + /// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view). + #[serde(default)] + pub share_view_options: bool, + /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). + #[serde(default)] + pub hide_locked_masks: bool, + /// On-canvas text editing (Type tool): the edited layer, selection and insertion style. + #[serde(default)] + pub text_edit: Option, + /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. + #[serde(skip)] + pub text_clipboard: Option, + /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). + #[serde(default)] + pub viewer: commands::viewer_cmds::ViewOptions, + /// Essential Graphics panel ▸ Primary composition (`None` = the active comp). + #[serde(default)] + pub essential_primary: Option, + /// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties + /// Essential Graphics can expose). + #[serde(default)] + pub essential_solo: bool, + /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call + /// `file.watchFolder.poll` periodically while it is set). + #[serde(default)] + pub watch_folder: Option, + /// The Footage panel: the footage shown, its time and In/Out marks (source time). + #[serde(default)] + pub footage_panel: Option, + /// Media Browser: the folder shown and the favourites. + #[serde(default)] + pub media_browser: media_browser::BrowserState, + /// Content-Aware Fill panel settings. + #[serde(default)] + pub content_fill: commands::content_fill::FillSettings, + /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. + #[serde(default)] + pub locked_viewer: Option, +} + +fn one_view() -> u8 { + 1 +} + +/// What Copy with Property Links / Copy Expression Only put on the clipboard. +#[derive(Clone, Debug, PartialEq)] +pub enum LinkClip { + /// Expressions that link back to the source properties: (match path, expression text). + Links { relative: bool, links: Vec<(String, String)> }, + /// Expressions only: (match path, expression). + Expressions(Vec<(String, effectcraft_project::Expression)>), +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 000000000..75e9e4467 --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,20 @@ +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active composition")] + NoComp, + /// A `comp` reference (name or id) that names no composition. + #[error("no composition {0}")] + NoSuchComp(String), + #[error("{0}")] + Project(#[from] effectcraft_project::ProjectError), + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs new file mode 100644 index 000000000..0e60575de --- /dev/null +++ b/crates/engine/src/event.rs @@ -0,0 +1,32 @@ +use super::*; + +/// Events for frontends (drained each frame). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum Event { + ProjectChanged { + revision: u64, + }, + Toast { + message: String, + error: bool, + }, + OpenComp(ItemId), + OpenUrl(String), + /// A frontend-only command (viewer zoom, panels, dialogs…) ran: the UI performs it. Headless + /// sessions ignore these. + Frontend { + command: String, + params: Value, + }, + /// Drop cached frames / renders (Edit ▸ Purge). + PurgeCaches, + /// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is + /// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are + /// not serialized. + CopyImage { + width: u32, + height: u32, + #[serde(skip)] + rgba: Vec, + }, +} diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 000000000..039fd2bad --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,92 @@ +use super::*; + +pub fn text_families() -> Vec { + effectcraft_text::families().into_iter().map(|(f, _)| f).collect() +} + +/// The styles a family offers (its own faces, in weight order), for the style menus. A family +/// that isn't installed offers the usual styles, which resolve to Inter's. +pub fn font_styles(family: &str) -> Vec { + effectcraft_text::families() + .into_iter() + .find(|(f, _)| f.eq_ignore_ascii_case(family)) + .map(|(_, st)| st) + .filter(|st| !st.is_empty()) + .unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect()) +} + +/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. +pub fn font_features(family: &str, style: &str) -> Vec { + effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() +} + +/// One row of the Character panel's font menu. +#[derive(Clone, Debug, PartialEq)] +pub struct FontRow { + /// The family to apply (empty for the separator after the recent fonts). + pub family: String, + /// What the menu shows: the English family name, or the font's own-language name when + /// Settings ▸ Type ▸ Show Font Names in English is off. + pub display: String, + pub recent: bool, + /// A variable font (the menu's Variable Fonts filter keeps only these). + pub variable: bool, +} + +/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for +/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). +pub fn font_preview(family: &str, size: f64) -> Vec> { + use kurbo::PathEl; + let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; + let mut out = vec![]; + let mut x = 0.0; + for ch in "Sample".chars() { + let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); + let mut cur: Vec<[f32; 2]> = vec![]; + let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; + kurbo::flatten(&path, 0.2, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur = vec![pt(p)]; + } + PathEl::LineTo(p) => cur.push(pt(p)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + x += adv; + } + out +} + +/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts +/// to Display), a separator, then every family. Each row says whether the family is variable, +/// for the menu's Variable Fonts filter. +pub fn font_menu(prefs: &prefs::Prefs) -> Vec { + let all = text_families(); + let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; + let variable = effectcraft_text::fonts::variable_families(); + let row = |f: &String, recent: bool| FontRow { + family: f.clone(), + display: native.get(f).cloned().unwrap_or_else(|| f.clone()), + recent, + variable: variable.contains(f), + }; + let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); + if !out.is_empty() { + out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false }); + } + out.extend(all.iter().map(|f| row(f, false))); + out +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fd834beca..2174a87ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,321 +81,20 @@ pub use system::perf; pub use system::storage; pub use system::sysinfo; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("unknown command `{0}`")] - UnknownCommand(String), - #[error("command `{0}` is not available right now: {1}")] - Disabled(String, String), - #[error("invalid parameters for `{cmd}`: {msg}")] - BadParams { cmd: String, msg: String }, - #[error("no active composition")] - NoComp, - /// A `comp` reference (name or id) that names no composition. - #[error("no composition {0}")] - NoSuchComp(String), - #[error("{0}")] - Project(#[from] effectcraft_project::ProjectError), - #[error("{0}")] - Other(String), -} +mod doc_state; +mod error; +mod event; +mod fonts; +mod services; +mod session; -pub type Result = std::result::Result; - -/// Host services (file access) injected by the frontend. -pub trait Services: Send + Sync { - fn read_file(&self, path: &str) -> std::io::Result>; - fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; - /// Whether a file exists (the background footage check after open). - fn exists(&self, path: &str) -> bool { - std::path::Path::new(path).exists() - } - /// A file's size and modification time (nanoseconds since 1970), to notice that it changed - /// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read. - fn stamp(&self, path: &str) -> Option<(u64, u128)> { - let meta = std::fs::metadata(path).ok()?; - let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; - Some((meta.len(), modified.as_nanos())) - } - /// Keep a file the app made for itself (footage extracted from a template): like - /// [`Services::write_file`], creating its folder, but never offered as a download (web: it - /// goes to browser storage only). - fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - if let Some(d) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - let _ = std::fs::create_dir_all(d); - } - self.write_file(path, data) - } - /// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence - /// detection on import. Empty when it can't be read. - fn list_dir(&self, dir: &str) -> Vec { - let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] }; - rd.filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.')) - .map(|e| e.path().to_string_lossy().to_string()) - .collect() - } - /// Whether `path` is a folder (dropped or picked folders import their files). - fn is_dir(&self, path: &str) -> bool { - std::path::Path::new(path).is_dir() - } -} - -/// Native filesystem. -pub struct FsServices; -impl Services for FsServices { - fn read_file(&self, path: &str) -> std::io::Result> { - std::fs::read(path) - } - fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - config::atomic_write(std::path::Path::new(path), data) - } -} - -/// Imports media files into the project (implemented by the media layer). -pub trait Importer: Send + Sync { - /// Probe one file and return footage metadata. Image sequences are the engine's - /// ([`sequence`]): it groups numbered stills and probes their first file here. - fn probe(&self, path: &str) -> std::result::Result; - /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media - /// layer reads from memory (the browser) register them; file-based ones need nothing. - fn register(&self, _path: &str, _data: &[u8]) {} - /// An image sequence's other frames (its first was probed) are footage now: hosts whose - /// media layer reads from memory (the browser) register them; file-based ones need nothing. - fn add_frames(&self, _paths: &[String]) {} -} - -/// A keyframe reference: layer, property uid, key time (layer time). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct KeyRef { - pub layer: LayerId, - pub prop: Uid, - pub time: Tick, -} - -/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it -/// started from and the total offset so far, so every step is applied to the keys as they were -/// before the drag. A key passed over on the way is never overwritten; only the drop position -/// counts, as in After Effects. -#[derive(Clone, Debug)] -pub struct KeyMove { - pub merge: String, - pub from: Vec, - pub total: Tick, -} - -/// Keyframes of one property on the keyframe clipboard. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct KeyClip { - pub layer: LayerId, - /// Match-id path relative to the layer (`transform/position`), portable between layers. - pub path: String, - /// Keys with times relative to the earliest copied key (layer time). - pub keys: Vec, -} - -/// A selected mask vertex: layer, mask group uid, vertex index. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct VertexRef { - pub layer: LayerId, - pub mask: Uid, - pub index: usize, -} - -/// One composition's selection. The active comp's copy lives in [`EditorState::selected_layers`] -/// (and the property, key, and vertex lists); every other comp keeps its own here (#1001). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct CompSelection { - pub layers: Vec, - pub props: Vec<(LayerId, Uid)>, - pub keys: Vec, - pub vertices: Vec, -} - -impl CompSelection { - fn is_empty(&self) -> bool { - self.layers.is_empty() && self.props.is_empty() && self.keys.is_empty() && self.vertices.is_empty() - } -} - -/// Editing state that commands depend on (headless-relevant, serde for agents). -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct EditorState { - pub active_comp: Option, - /// Open compositions (viewer/timeline tabs), in tab order. - pub open_comps: Vec, - /// Compositions that were active before the current one, most recent first (Shift+Esc). - #[serde(default)] - pub recent_comps: Vec, - /// Current time per comp (the CTI). - pub times: std::collections::BTreeMap, - /// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`]. - pub selected_layers: Vec, - /// Selections of comps that are not active, keyed by comp id (#1001). - #[serde(default)] - pub comp_selections: std::collections::BTreeMap, - /// Selected properties / groups (by uid) of selected layers. - pub selected_props: Vec<(LayerId, Uid)>, - pub selected_keys: Vec, - pub project_selection: Vec, - /// Layer clipboard (serialized layers) and keyframe clipboard. - #[serde(skip)] - pub clipboard: Vec, - /// Copied keyframes (times relative to the earliest copied key). - #[serde(skip)] - pub key_clipboard: Vec, - /// The last copy was keyframes (Paste pastes keys at the CTI). - #[serde(skip)] - pub clip_is_keys: bool, - /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; - /// Paste sets the same properties of the selected layers. - #[serde(skip)] - pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, - /// Copied effect instances, with comp-time key offsets from the earliest copied key. - /// Paste adds them to the selected layers at the destination comp's CTI. - #[serde(skip)] - pub effect_clipboard: Vec, - /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in - /// a shape layer's Contents); Paste adds them to the selected shape layers. - #[serde(skip)] - pub contents_clipboard: Vec, - /// Selected mask vertices (viewer Selection tool / pen). - #[serde(default)] - pub selected_vertices: Vec, - pub snapping: bool, - /// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended). - #[serde(default)] - pub snap_features: viewer::SnapFeatures, - /// Last applied effect id (Effect ▸ last effect). - pub last_effect: Option, - /// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region - /// of Interest). - #[serde(default)] - pub region_of_interest: Option<[f64; 4]>, - /// Property-link clipboard (Edit ▸ Copy with Property Links / Copy Expression Only). - #[serde(skip)] - pub link_clipboard: Option, - /// File ▸ Interpret Footage ▸ Remember Interpretation. - #[serde(skip)] - pub interpretation: Option, - /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). - #[serde(default)] - pub views3d: std::collections::BTreeMap, - /// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp. - #[serde(default)] - pub current_track: Option<(LayerId, Uid)>, - /// Tracker panel ▸ Method in mask mode (Track Mask). - #[serde(default)] - pub mask_track_method: mask_track::MaskMethod, - /// 3D Camera Tracker: the selected track points (ids) in the viewer. - #[serde(default)] - pub camera_points: Vec, - /// Mask Interpolation panel options. - #[serde(default)] - pub mask_interp: commands::mask_interp::MaskInterpOptions, - /// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools). - #[serde(default)] - pub paint: commands::paint::PaintOptions, - /// Roto Brush and Refine Edge tool options. - #[serde(default)] - pub roto: roto::RotoOptions, - /// Puppet tool options for new meshes. - #[serde(default)] - pub puppet: commands::puppet::PuppetOptions, - /// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke). - #[serde(default)] - pub shape_tool: commands::shape_tool::ShapeTool, - /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. - #[serde(default = "one_view")] - pub view_layout: u8, - /// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view). - #[serde(default)] - pub share_view_options: bool, - /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). - #[serde(default)] - pub hide_locked_masks: bool, - /// On-canvas text editing (Type tool): the edited layer, selection and insertion style. - #[serde(default)] - pub text_edit: Option, - /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. - #[serde(skip)] - pub text_clipboard: Option, - /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). - #[serde(default)] - pub viewer: commands::viewer_cmds::ViewOptions, - /// Essential Graphics panel ▸ Primary composition (`None` = the active comp). - #[serde(default)] - pub essential_primary: Option, - /// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties - /// Essential Graphics can expose). - #[serde(default)] - pub essential_solo: bool, - /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call - /// `file.watchFolder.poll` periodically while it is set). - #[serde(default)] - pub watch_folder: Option, - /// The Footage panel: the footage shown, its time and In/Out marks (source time). - #[serde(default)] - pub footage_panel: Option, - /// Media Browser: the folder shown and the favourites. - #[serde(default)] - pub media_browser: media_browser::BrowserState, - /// Content-Aware Fill panel settings. - #[serde(default)] - pub content_fill: commands::content_fill::FillSettings, - /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. - #[serde(default)] - pub locked_viewer: Option, -} - -fn one_view() -> u8 { - 1 -} - -/// `t` as a current time of `comp`: a frame within its duration. -fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) -} - -/// What Copy with Property Links / Copy Expression Only put on the clipboard. -#[derive(Clone, Debug, PartialEq)] -pub enum LinkClip { - /// Expressions that link back to the source properties: (match path, expression text). - Links { relative: bool, links: Vec<(String, String)> }, - /// Expressions only: (match path, expression). - Expressions(Vec<(String, effectcraft_project::Expression)>), -} - -/// Events for frontends (drained each frame). -#[derive(Clone, Debug, PartialEq, Serialize)] -pub enum Event { - ProjectChanged { - revision: u64, - }, - Toast { - message: String, - error: bool, - }, - OpenComp(ItemId), - OpenUrl(String), - /// A frontend-only command (viewer zoom, panels, dialogs…) ran: the UI performs it. Headless - /// sessions ignore these. - Frontend { - command: String, - params: Value, - }, - /// Drop cached frames / renders (Edit ▸ Purge). - PurgeCaches, - /// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is - /// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are - /// not serialized. - CopyImage { - width: u32, - height: u32, - #[serde(skip)] - rgba: Vec, - }, -} +pub use cmd::{animation_presets, text_presets}; +pub use doc_state::{CompSelection, EditorState, KeyClip, KeyMove, KeyRef, LinkClip, VertexRef}; +pub use error::{EngineError, Result}; +pub use event::Event; +pub use fonts::{FontRow, font_features, font_menu, font_preview, font_styles, text_families}; +pub use services::{FsServices, Importer, Services}; +pub use session::upgrade_effects; pub struct Session { pub project: Arc, @@ -649,847 +348,7 @@ impl Default for Session { } } -impl Session { - pub fn new() -> Session { - Session::default() - } - - /// Run a command by id. - pub fn execute(&mut self, id: &str, params: Value) -> Result { - let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - if let Err(why) = (spec.enabled)(self) { - // Explicit targets (agents, scripts) don't need a UI selection. - let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok()) - || ["item", "items"].iter().any(|k| params.get(k).is_some()) - // An explicit composition (the enablement looks at the active one). - || params.get("comp").is_some_and(|c| !c.is_null() && self.resolve_comp(Some(c)).is_ok()); - if !explicit { - return Err(EngineError::Disabled(id.to_string(), why)); - } - } - // Last-resort guard: a panicking command reports an error instead of crashing the app. - let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?; - self.learn_observe(id, ¶ms); - if spec.journal { - self.journal.push((id.to_string(), params)); - if self.journal.len() > 10_000 { - self.journal.drain(..1000); - } - } - Ok(r) - } - - /// [`Session::execute`] for agents and scripts (MCP, CLI, control channel): unknown top-level - /// parameters are rejected with the accepted keys instead of being silently ignored. - pub fn execute_checked(&mut self, id: &str, params: Value) -> Result { - let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - commands::check_params(spec, ¶ms)?; - self.execute(id, params) - } - - /// [`Session::execute`] for a command a script asked for (the JavaScript engine's - /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: - /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ - /// Application Scripting), which only the user can, from the UI, CLI or control channel. - pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { - self.script_calls.0 = self.script_calls.0.saturating_add(1); - let r = self.execute(id, params); - self.script_calls.0 = self.script_calls.0.saturating_sub(1); - r - } - - /// Whether the command running was issued by a script ([`Session::execute_for_script`]). - pub fn running_for_script(&self) -> bool { - self.script_calls.0 > 0 - } - - /// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session; - /// an unknown id or a predicate error reads as disabled. - pub fn is_enabled(&self, id: &str) -> bool { - commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) - } - - /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. - /// - /// Every edit is an external boundary for the on-canvas text editor: if it replaced the - /// document the editor is showing, the selection is clamped and the editor gets a fresh IME - /// epoch. The editor's own typing goes through `edit_text_doc`, which owns that - /// document and must not treat its own keystrokes as a replacement. - pub fn edit(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { - let edited = commands::text_edit::active_editor_snapshot(self); - let out = self.edit_inner(label, merge, f)?; - commands::text_edit::after_external_change(self, edited.as_ref()); - Ok(out) - } - - /// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one - /// the editor owns, so a change here is typing, not a replacement behind the IME's back. - pub(crate) fn edit_text_doc(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { - self.edit_inner(label, merge, f) - } - - /// The shared body of [`Session::edit`] and [`Session::edit_text_doc`]. - /// - /// Runs `f` on a clone of the project and editor state, then synchronizes derived data (global - /// light and Essential Graphics), queues invalidated Warp Stabilizer/camera-tracker work and - /// Roto Brush instances needing re-propagation in the `*_pending` lists. If the clone has the same - /// content the project and history are left as they were (only editor-state changes from `f` - /// are kept) and no revision is bumped; otherwise the previous project is recorded as an - /// undo step under `label`, unless it merges with the current merge key (then the redo branch - /// is dropped). An `Err` from `f` propagates with the session unchanged. - fn edit_inner(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { - let before = self.project.clone(); - let mut p = (*self.project).clone(); - let mut st = self.state.clone(); - let r = f(&mut p, &mut st)?; - // Layer styles: one Global Light per comp, whichever layer edited it. - effectcraft_project::styles::sync_global_light(&before, &mut p); - // Essential Properties of precomp layers follow their comps' Essential Graphics. - effectcraft_project::essential::sync_project(&before, &mut p); - // Warp Stabilizer analyses made from other frames are cleared (and queued again). - for w in warp::invalidate(&before, &mut p) { - if !self.warp_pending.contains(&w) { - self.warp_pending.push(w); - } - } - // So are 3D Camera Tracker tracks (and solves made with other settings). - for w in camera_track::invalidate(&before, &mut p) { - if !self.camera_pending.contains(&w) { - self.camera_pending.push(w); - } - } - // Roto Brush Input Keys follow the frames; edited instances propagate again. - for r in roto::sync(&before, &mut p) { - if !self.roto_pending.contains(&r) { - self.roto_pending.push(r); - } - } - // Nothing changed (a value set to what it already was): no undo step, not modified. - if p.same_content(&before) { - self.state = st; - return Ok(r); - } - let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key; - if !same { - // Undone steps aren't lost: they stay in the History panel as a branch. - let levels = self.prefs.general.undo_levels.max(1) as usize; - self.history.record(label, before, levels); - } else { - self.history.abandon_redo(&before); - } - self.history.merge_key = merge.map(str::to_string); - self.project = Arc::new(p); - self.state = st; - self.bump(); - Ok(r) - } - - /// Note a change to the on-canvas editor's committed text or session. - /// - /// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated - /// project change must not advance it. - pub(crate) fn note_text_edit_change(&mut self) { - // Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion - // keep the previous value; an unavailable before/after pair then compares equal and - // earns no receipt instead of accepting a stale revision. - if let Some(revision) = commands::text_edit::next_ime_revision() { - self.ime_revision = self.ime_revision.max(revision); - } - } - - pub fn bump(&mut self) { - self.revision += 1; - // Back at the saved state (undo, redo, a history jump or a rolled-back batch). - if Arc::ptr_eq(&self.project, &self.saved_project) { - self.saved_revision = self.revision; - } - self.refresh_disk_salt(false); - self.events.push(Event::ProjectChanged { revision: self.revision }); - } - - /// Salt the disk cache's layer keys with the project's footage files - /// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of - /// every file, every frame of an image sequence included, so it is taken again only when the - /// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache - /// attached), not at every edit: with a few large sequences that cost each keystroke in a - /// text field over half a second (#1926). - pub fn refresh_disk_salt(&mut self, force: bool) -> Option { - use std::hash::{Hash, Hasher}; - if self.disk_cache.is_none() { - self.disk_salt = None; - return None; - } - let mut h = std::collections::hash_map::DefaultHasher::new(); - for (id, it) in &self.project.items { - if let effectcraft_project::ItemKind::Footage(f) = &it.kind { - id.hash(&mut h); - format!("{f:?}").hash(&mut h); - } - } - let footage = h.finish(); - if let Some((seen, salt)) = self.disk_salt - && seen == footage - && !force - { - return Some(salt); - } - let salt = effectcraft_render::disk_cache::footage_salt(&self.project); - self.disk_salt = Some((footage, salt)); - self.layer_cache.set_disk_salt(salt); - Some(salt) - } - - /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes - /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay - /// valid ([`effectcraft_project::Comp::same_pixels`]). - pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> { - if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r { - return Ok(()); - } - Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r; - self.bump(); - Ok(()) - } - - /// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the - /// project before undo, redo or a history jump restored another state. - pub(crate) fn keep_view_settings(&mut self, from: &Project) { - let keep: Vec<(ItemId, effectcraft_project::Resolution)> = - self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect(); - if keep.is_empty() { - return; - } - let p = Arc::make_mut(&mut self.project); - for (id, r) in keep { - if let Some(c) = p.comp_mut(id) { - c.resolution = r; - } - } - } - - /// Undoes one step: the current project goes onto the redo stack, per-comp Resolution is kept - /// as it was, the merge key is cleared, selections are sanitized for the replaced state (the - /// on-canvas editor gets a new IME epoch only when its own document changed) and the session is - /// bumped. Returns `false`, changing nothing, when there is nothing to undo. - pub fn undo(&mut self) -> bool { - let Some((label, p)) = self.history.undo.pop() else { return false }; - let edited = commands::text_edit::active_editor_snapshot(self); - let cur = std::mem::replace(&mut self.project, p); - self.keep_view_settings(&cur); - self.history.redo.push((label, cur)); - self.history.merge_key = None; - self.sanitize_after_replacement(edited.as_ref()); - self.bump(); - true - } - - /// Redoes one step: the current project goes back onto the undo stack, per-comp Resolution is - /// kept, the merge key is cleared, selections are sanitized for the replaced state (a changed - /// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing - /// nothing, when there is nothing to redo. - pub fn redo(&mut self) -> bool { - let Some((label, p)) = self.history.redo.pop() else { return false }; - let edited = commands::text_edit::active_editor_snapshot(self); - let cur = std::mem::replace(&mut self.project, p); - self.keep_view_settings(&cur); - self.history.undo.push((label, cur)); - self.history.merge_key = None; - self.sanitize_after_replacement(edited.as_ref()); - self.bump(); - true - } - - /// Keep selections valid after the project was replaced by another state (undo, redo, a - /// history jump, a rollback): the same layer id can name a different document, so the - /// on-canvas editor gets a fresh IME epoch only when its own document changed. - /// - /// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the - /// state this call is about to replace. Atomic batch rollback is the exception: it has - /// already restored the pre-batch state and passes the snapshot of the failing intermediate - /// state, so a transient restart or component switch inside the batch still rejects reviving - /// the old composition. A restored state that still has the same composition, layer and - /// editor session and shows byte/style-equivalent text (an opacity or other property undo) - /// keeps the editor's owner and selection; a different document, comp, layer or session clamps the - /// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's - /// matching layer id never inherits the old comp's session. `None` means no editor was open - /// when the snapshot was taken; if one is open after the replacement it is treated as an - /// unverified document replacement, so it never inherits an old composition. - pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) { - self.sanitize_state(); - commands::text_edit::after_replacement(self, before); - } - - /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id - /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. - pub fn sanitize_state(&mut self) { - let p = self.project.clone(); - self.state.open_comps.retain(|c| p.comp(*c).is_some()); - self.state.comp_selections.retain(|cid, sel| { - let Some(c) = p.comp(*cid) else { return false }; - let ids: Vec = c.layers.iter().map(|l| l.id).collect(); - sel.layers.retain(|l| ids.contains(l)); - sel.props.retain(|(l, _)| ids.contains(l)); - sel.keys.retain(|k| ids.contains(&k.layer)); - sel.vertices.retain(|v| ids.contains(&v.layer)); - !sel.is_empty() - }); - if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { - self.clear_live_selection(); - // A matching layer id in the fallback comp is not the document being edited. - self.state.text_edit = None; - self.state.active_comp = self.state.open_comps.first().copied(); - if let Some(id) = self.state.active_comp { - self.recall_selection(id); - } - } - if let Some(c) = self.active_comp() { - let ids: Vec = c.layers.iter().map(|l| l.id).collect(); - self.state.selected_layers.retain(|l| ids.contains(l)); - self.state.selected_props.retain(|(l, _)| ids.contains(l)); - self.state.selected_keys.retain(|k| ids.contains(&k.layer)); - self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); - } - if let Some((l, t)) = self.state.current_track - && self.active_comp().and_then(|c| c.layer(l)).and_then(|l| l.tracker(t)).is_none() - { - self.state.current_track = None; - } - self.state.project_selection.retain(|i| p.item(*i).is_some()); - commands::text_edit::sanitize(self); - } - - pub fn is_dirty(&self) -> bool { - self.revision != self.saved_revision - } - - /// The project as it is now is the saved one (after opening or saving it). - pub fn mark_saved(&mut self) { - self.saved_project = self.project.clone(); - self.saved_revision = self.revision; - } - - /// The project has changes its file doesn't (a session restored with unsaved changes): it - /// stays modified until it is saved, whatever is undone. - pub fn mark_unsaved(&mut self) { - self.saved_project = Arc::new(Project::default()); - self.bump(); - } - - pub fn active_comp(&self) -> Option<&Comp> { - self.project.comp(self.state.active_comp?) - } - /// [`Session::active_comp`] as a shared snapshot (an `Arc` clone, not a deep copy). - pub fn active_comp_arc(&self) -> Option> { - self.project.comp_arc(self.state.active_comp?) - } - pub fn active_comp_id(&self) -> Option { - self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) - } - - /// Current time of a comp. - pub fn time_of(&self, comp: ItemId) -> Tick { - self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO) - } - - /// Current time of the active comp. - pub fn time(&self) -> Tick { - self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) - } - - /// Move the active comp's current time. - /// - /// Time is an external boundary for the on-canvas text editor too: an animated Source Text - /// can show a different document at another time. The document is snapshotted only while an - /// editor is open, so playback with no text editor does no text copying. - pub fn set_time(&mut self, t: Tick) { - if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - let edited = commands::text_edit::active_editor_snapshot(self); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - // Direct call: this `set_time` owns the editor snapshot for both time changes, - // so a synced move does not take a second snapshot or start a second epoch. - self.sync_related_times_inner(c, t); - } - commands::text_edit::after_external_change(self, edited.as_ref()); - } - } - - /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of - /// `comp` moves the current time of the comps nested in it (through their precomp layers' - /// timing) and of the comps it is nested in, so every related viewer shows the same moment. - pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { - // Moving a related comp can move the active one, so the editor's document is checked - // here too; the snapshot is skipped entirely when no text editor is open. - let edited = commands::text_edit::active_editor_snapshot(self); - self.sync_related_times_inner(comp, t); - commands::text_edit::after_external_change(self, edited.as_ref()); - } - - /// Sets the time of every comp related to `comp` through precomp layers: down into the comps - /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps - /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to - /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. - fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { - let mut seen = std::collections::BTreeSet::from([comp]); - let mut todo = vec![(comp, t)]; - let mut found = vec![]; - while let Some((cid, ct)) = todo.pop() { - if cid != comp { - found.push((cid, ct)); - } - let Some(c) = self.project.comp(cid) else { continue }; - // Down: the comps this one nests. - for l in &c.layers { - if let effectcraft_project::LayerSource::Comp { item } = &l.source - && seen.insert(*item) - { - todo.push((*item, l.layer_time(ct))); - } - } - // Up: the comps nesting this one. - for iid in self.project.items.keys() { - if let Some(pc) = self.project.comp(*iid) - && let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid)) - && seen.insert(*iid) - { - todo.push((*iid, l.comp_time(ct))); - } - } - } - for (cid, ct) in found { - if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); - self.state.times.insert(cid, ct); - } - } - } - - /// Make `id` the active comp, parking the previous comp's selection and restoring this one's. - pub fn activate_comp(&mut self, id: ItemId) { - if self.project.comp(id).is_none() || self.state.active_comp == Some(id) { - return; - } - self.remember_selection(); - self.state.active_comp = Some(id); - self.recall_selection(id); - self.state.current_track = None; - // A text-edit session belongs to the comp that owned it. Switching comps ends it; even - // a same-numbered layer in the new comp must not inherit the old IME composition. - self.state.text_edit = None; - } - - /// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`]. - pub fn activate_comp_none(&mut self) { - self.remember_selection(); - self.state.active_comp = None; - self.clear_live_selection(); - self.state.current_track = None; - self.state.text_edit = None; - } - - fn remember_selection(&mut self) { - let Some(id) = self.state.active_comp else { return }; - let sel = CompSelection { - layers: self.state.selected_layers.clone(), - props: self.state.selected_props.clone(), - keys: self.state.selected_keys.clone(), - vertices: self.state.selected_vertices.clone(), - }; - if sel.is_empty() { - self.state.comp_selections.remove(&id); - } else { - self.state.comp_selections.insert(id, sel); - } - } - - fn recall_selection(&mut self, id: ItemId) { - match self.state.comp_selections.remove(&id) { - Some(sel) => { - self.state.selected_layers = sel.layers; - self.state.selected_props = sel.props; - self.state.selected_keys = sel.keys; - self.state.selected_vertices = sel.vertices; - } - None => self.clear_live_selection(), - } - } - - fn clear_live_selection(&mut self) { - self.state.selected_layers.clear(); - self.state.selected_props.clear(); - self.state.selected_keys.clear(); - self.state.selected_vertices.clear(); - } - - /// Open a comp in the viewer/timeline and make it active. - pub fn open_comp(&mut self, id: ItemId) { - if self.project.comp(id).is_none() { - return; - } - if !self.state.open_comps.contains(&id) { - self.state.open_comps.push(id); - } - if self.state.active_comp != Some(id) { - let previous = self.state.active_comp; - self.state.recent_comps.retain(|c| *c != id && Some(*c) != previous); - if let Some(previous) = previous { - self.state.recent_comps.insert(0, previous); - self.state.recent_comps.truncate(32); - } - } - self.activate_comp(id); - self.events.push(Event::OpenComp(id)); - } - - /// The open compositions and their current times, as a project file records them - /// ([`effectcraft_project::SavedView`]). - pub fn saved_view(&self) -> effectcraft_project::SavedView { - let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect(); - effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times } - } - - /// Reopen the compositions a project file was saved with (#748), each at its saved current - /// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays - /// on the comp [`Session::replace_project`] opened (its first), as files from before the view - /// was saved do. - pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) { - for (id, t) in &view.comp_times { - if let Some(comp) = self.project.comp(*id) { - let t = comp_frame_at(comp, *t); - self.state.times.insert(*id, t); - } - } - let mut open: Vec = Vec::new(); - for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) { - if !open.contains(&id) { - open.push(id); - } - } - let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return }; - self.state.open_comps = open; - self.open_comp(active); - } - - pub fn toast(&mut self, msg: impl Into) { - self.events.push(Event::Toast { message: msg.into(), error: false }); - } - - pub fn drain_events(&mut self) -> Vec { - std::mem::take(&mut self.events) - } - - /// The 3D view camera of a comp's viewer (None = the active camera). - pub fn view_camera(&self, comp: ItemId) -> Option { - let c = self.project.comp(comp)?; - if !c.has_3d() { - return None; - } - self.state.views3d.get(&comp)?.override_camera(c.width as f64, c.height as f64) - } - - /// Render a comp frame with the session's footage + expression hosts. - pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { - self.renderer(opts).comp_frame(comp, t) - } - - /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and - /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. - pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { - let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; - let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); - r.expr = self.expr.as_deref(); - r.cache = Some(&self.layer_cache); - r.accel = self.accel.as_deref(); - r - } - - /// Resolve a comp reference (item id number or name; `None`/null = the active comp). - pub fn resolve_comp(&self, comp: Option<&Value>) -> Result { - let p = match comp { - Some(v) if !v.is_null() => serde_json::json!({ "comp": v }), - _ => serde_json::json!({}), - }; - commands::comp_id(self, &p) - } - - /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled - /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba). - pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec)> { - let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - Ok((img.width, img.height, img.to_rgba8_over(c.background))) - } - - /// A Project panel thumbnail of an item (a comp at `t`, footage at its first frame), longest - /// side at most `max_side`, as a job that owns everything it reads: frontends run it off the - /// UI thread (a big comp takes far longer than a frame). `None` for items without pixels. - pub fn thumbnail_job(&self, item: ItemId, t: Tick, max_side: u32) -> Option { - let it = self.project.item(item)?; - let footage = self.footage.clone(); - match &it.kind { - effectcraft_project::ItemKind::Comp(c) => { - let (project, expr, cache) = (self.project.clone(), self.expr.clone(), self.layer_cache.clone()); - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let opts = RenderOpts { - scale, - nested_switches: self.prefs.general.switches_affect_nested_comps, - draft_shadows: self.prefs.three_d.realtime_shadows, - ..Default::default() - }; - let bg = c.background; - Some(Box::new(move || { - let mut r = Renderer::new(&project, footage.as_ref(), opts); - r.expr = expr.as_deref(); - r.cache = Some(&cache); - let img = r.comp_frame(item, t); - Some((img.width, img.height, img.to_rgba8_over(bg))) - })) - } - effectcraft_project::ItemKind::Footage(f) if f.has_video => { - let f = f.clone(); - Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; - // Point-sample down to at most `max_side` on the long side. - let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); - let full = img.to_rgba8(); - let mut px = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; - px.extend_from_slice(&full[i..i + 4]); - } - } - Some((w, h, px)) - })) - } - _ => None, - } - } - - /// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a - /// render with RGB + Alpha channels writes) instead of compositing over the background. - pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec)> { - if !transparent { - return self.render_rgba8(comp, t, max_side); - } - let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - Ok((img.width, img.height, img.to_rgba8())) - } - - /// Make the media layer and the layer cache let go of what they decoded from `footage`'s - /// files (it keys frames by path, so an overwritten file would keep showing its old pixels). - pub(crate) fn forget_decoded(&self, footage: &effectcraft_project::Footage) { - for p in std::iter::once(&footage.path).chain(&footage.sequence) { - self.footage.forget(p); - } - self.layer_cache.clear(); - } - - /// Replace the whole project (open/new), resetting history and state. - pub fn replace_project(&mut self, mut p: Project, path: Option) { - if self.stop_render() - && let Some(mut job) = self.render_job.take() - { - // Let the worker notice the cancel; its updates refer to the old project. - job.wait(); - } - self.render_job = None; - self.stored_paths = Default::default(); - self.stop_track(); - self.track_job = None; - self.stop_mask_track(); - self.mask_job = None; - self.stop_warp(); - self.warp_job = None; - self.warp_pending.clear(); - self.stop_camera(); - self.camera_job = None; - self.camera_pending.clear(); - self.stop_roto(); - self.roto_job = None; - self.roto_pending.clear(); - self.drop_tasks(); - self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1); - p.fix_next_id(); - upgrade_effects(&mut p); - // The media layer keys decoded frames by path: files of the new project may have changed - // since an earlier project decoded them (#1070). - for it in p.items.values() { - if let effectcraft_project::ItemKind::Footage(f) = &it.kind { - self.forget_decoded(f); - } - } - self.layer_cache.clear(); - self.project = Arc::new(p); - // Its files may have changed since they were last stamped. - self.disk_salt = None; - self.history = History::default(); - self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; - self.path = path; - self.bump(); - self.mark_saved(); - let first = self.project.comps().next().map(|(id, _)| *id); - if let Some(c) = first { - self.open_comp(c); - } - } -} - -/// Bring effect instances saved by earlier versions up to date with the effect registry -/// (renamed / regrouped / added parameters, reordered popups; see -/// [`effectcraft_effects::migrate`]), and a project from before -/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space -/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. -pub fn upgrade_effects(p: &mut Project) { - let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE; - // Effect bounds per layer: (size, top-left in layer coordinates). - let mut bounds = std::collections::HashMap::new(); - for (cid, c) in p.comps() { - for l in &c.layers { - bounds.insert((*cid, l.id), effectcraft_render::effect_bounds(p, c, l)); - } - } - let mut next = p.next_id; - for (cid, it) in p.items.iter_mut() { - let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue }; - for l in &mut std::sync::Arc::make_mut(c).layers { - let (size, origin) = bounds.get(&(*cid, l.id)).copied().unwrap_or(([100.0, 100.0], [0.0; 2])); - // Effect space starts at the bounds' top-left: positions move by minus that corner. - // Adjustment layers run their effects on the comp below as it is: nothing moves. - let shift = |o: [f64; 2]| [-o[0], -o[1]]; - let d = if l.switches.adjustment { [0.0; 2] } else { shift(origin) }; - let clone_d = |src: Option| match src { - Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)), - None => d, - }; - let Some(fx) = l.props.sub_mut("effects") else { continue }; - for n in &mut fx.children { - let effectcraft_project::Node::Group(g) = n else { continue }; - let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue }; - if let Some(spec) = effectcraft_effects::find(effect) { - effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size); - if to_effect_space { - effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d); - } - } - } - } - } - p.next_id = next; - p.schema = effectcraft_project::SCHEMA_VERSION; -} - #[cfg(test)] mod rq_tests; #[cfg(test)] mod tests; - -/// Font families available to text layers (bundled + scanned system fonts). -/// Text animation presets: (id, name), for menus. -pub fn text_presets() -> Vec<(String, String)> { - commands::text_preset_list() -} - -/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category). -pub fn animation_presets() -> Vec<(String, String, String)> { - commands::builtin_preset_list() -} - -pub fn text_families() -> Vec { - effectcraft_text::families().into_iter().map(|(f, _)| f).collect() -} - -/// The styles a family offers (its own faces, in weight order), for the style menus. A family -/// that isn't installed offers the usual styles, which resolve to Inter's. -pub fn font_styles(family: &str) -> Vec { - effectcraft_text::families() - .into_iter() - .find(|(f, _)| f.eq_ignore_ascii_case(family)) - .map(|(_, st)| st) - .filter(|st| !st.is_empty()) - .unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect()) -} - -/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. -pub fn font_features(family: &str, style: &str) -> Vec { - effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() -} - -/// One row of the Character panel's font menu. -#[derive(Clone, Debug, PartialEq)] -pub struct FontRow { - /// The family to apply (empty for the separator after the recent fonts). - pub family: String, - /// What the menu shows: the English family name, or the font's own-language name when - /// Settings ▸ Type ▸ Show Font Names in English is off. - pub display: String, - pub recent: bool, - /// A variable font (the menu's Variable Fonts filter keeps only these). - pub variable: bool, -} - -/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for -/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). -pub fn font_preview(family: &str, size: f64) -> Vec> { - use kurbo::PathEl; - let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; - let mut out = vec![]; - let mut x = 0.0; - for ch in "Sample".chars() { - let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); - let mut cur: Vec<[f32; 2]> = vec![]; - let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; - kurbo::flatten(&path, 0.2, |el| match el { - PathEl::MoveTo(p) => { - if cur.len() > 1 { - out.push(std::mem::take(&mut cur)); - } - cur = vec![pt(p)]; - } - PathEl::LineTo(p) => cur.push(pt(p)), - PathEl::ClosePath => { - if let Some(f) = cur.first().copied() { - cur.push(f); - } - if cur.len() > 1 { - out.push(std::mem::take(&mut cur)); - } - } - _ => {} - }); - if cur.len() > 1 { - out.push(cur); - } - x += adv; - } - out -} - -/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts -/// to Display), a separator, then every family. Each row says whether the family is variable, -/// for the menu's Variable Fonts filter. -pub fn font_menu(prefs: &prefs::Prefs) -> Vec { - let all = text_families(); - let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; - let variable = effectcraft_text::fonts::variable_families(); - let row = |f: &String, recent: bool| FontRow { - family: f.clone(), - display: native.get(f).cloned().unwrap_or_else(|| f.clone()), - recent, - variable: variable.contains(f), - }; - let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); - if !out.is_empty() { - out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false }); - } - out.extend(all.iter().map(|f| row(f, false))); - out -} diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs new file mode 100644 index 000000000..01a2102ec --- /dev/null +++ b/crates/engine/src/services.rs @@ -0,0 +1,65 @@ +use super::*; + +/// Host services (file access) injected by the frontend. +pub trait Services: Send + Sync { + fn read_file(&self, path: &str) -> std::io::Result>; + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; + /// Whether a file exists (the background footage check after open). + fn exists(&self, path: &str) -> bool { + std::path::Path::new(path).exists() + } + /// A file's size and modification time (nanoseconds since 1970), to notice that it changed + /// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read. + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((meta.len(), modified.as_nanos())) + } + /// Keep a file the app made for itself (footage extracted from a template): like + /// [`Services::write_file`], creating its folder, but never offered as a download (web: it + /// goes to browser storage only). + fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + if let Some(d) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { + let _ = std::fs::create_dir_all(d); + } + self.write_file(path, data) + } + /// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence + /// detection on import. Empty when it can't be read. + fn list_dir(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] }; + rd.filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.')) + .map(|e| e.path().to_string_lossy().to_string()) + .collect() + } + /// Whether `path` is a folder (dropped or picked folders import their files). + fn is_dir(&self, path: &str) -> bool { + std::path::Path::new(path).is_dir() + } +} + +/// Native filesystem. +pub struct FsServices; + +impl Services for FsServices { + fn read_file(&self, path: &str) -> std::io::Result> { + std::fs::read(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + config::atomic_write(std::path::Path::new(path), data) + } +} + +/// Imports media files into the project (implemented by the media layer). +pub trait Importer: Send + Sync { + /// Probe one file and return footage metadata. Image sequences are the engine's + /// ([`sequence`]): it groups numbered stills and probes their first file here. + fn probe(&self, path: &str) -> std::result::Result; + /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media + /// layer reads from memory (the browser) register them; file-based ones need nothing. + fn register(&self, _path: &str, _data: &[u8]) {} + /// An image sequence's other frames (its first was probed) are footage now: hosts whose + /// media layer reads from memory (the browser) register them; file-based ones need nothing. + fn add_frames(&self, _paths: &[String]) {} +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 000000000..3f959344a --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,749 @@ +use super::doc_state::{CompSelection, EditorState}; +use super::error::{EngineError, Result}; +use super::event::Event; +use super::*; + +/// `t` as a current time of `comp`: a frame within its duration. +fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { + comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) +} + +impl Session { + pub fn new() -> Session { + Session::default() + } + + /// Run a command by id. + pub fn execute(&mut self, id: &str, params: Value) -> Result { + let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if let Err(why) = (spec.enabled)(self) { + // Explicit targets (agents, scripts) don't need a UI selection. + let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok()) + || ["item", "items"].iter().any(|k| params.get(k).is_some()) + // An explicit composition (the enablement looks at the active one). + || params.get("comp").is_some_and(|c| !c.is_null() && self.resolve_comp(Some(c)).is_ok()); + if !explicit { + return Err(EngineError::Disabled(id.to_string(), why)); + } + } + // Last-resort guard: a panicking command reports an error instead of crashing the app. + let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?; + self.learn_observe(id, ¶ms); + if spec.journal { + self.journal.push((id.to_string(), params)); + if self.journal.len() > 10_000 { + self.journal.drain(..1000); + } + } + Ok(r) + } + + /// [`Session::execute`] for agents and scripts (MCP, CLI, control channel): unknown top-level + /// parameters are rejected with the accepted keys instead of being silently ignored. + pub fn execute_checked(&mut self, id: &str, params: Value) -> Result { + let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + commands::check_params(spec, ¶ms)?; + self.execute(id, params) + } + + /// [`Session::execute`] for a command a script asked for (the JavaScript engine's + /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: + /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ + /// Application Scripting), which only the user can, from the UI, CLI or control channel. + pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { + self.script_calls.0 = self.script_calls.0.saturating_add(1); + let r = self.execute(id, params); + self.script_calls.0 = self.script_calls.0.saturating_sub(1); + r + } + + /// Whether the command running was issued by a script ([`Session::execute_for_script`]). + pub fn running_for_script(&self) -> bool { + self.script_calls.0 > 0 + } + + /// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session; + /// an unknown id or a predicate error reads as disabled. + pub fn is_enabled(&self, id: &str) -> bool { + commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) + } + + /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. + /// + /// Every edit is an external boundary for the on-canvas text editor: if it replaced the + /// document the editor is showing, the selection is clamped and the editor gets a fresh IME + /// epoch. The editor's own typing goes through `edit_text_doc`, which owns that + /// document and must not treat its own keystrokes as a replacement. + pub fn edit(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + let edited = commands::text_edit::active_editor_snapshot(self); + let out = self.edit_inner(label, merge, f)?; + commands::text_edit::after_external_change(self, edited.as_ref()); + Ok(out) + } + + /// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one + /// the editor owns, so a change here is typing, not a replacement behind the IME's back. + pub(crate) fn edit_text_doc(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + self.edit_inner(label, merge, f) + } + + /// The shared body of [`Session::edit`] and [`Session::edit_text_doc`]. + /// + /// Runs `f` on a clone of the project and editor state, then synchronizes derived data (global + /// light and Essential Graphics), queues invalidated Warp Stabilizer/camera-tracker work and + /// Roto Brush instances needing re-propagation in the `*_pending` lists. If the clone has the same + /// content the project and history are left as they were (only editor-state changes from `f` + /// are kept) and no revision is bumped; otherwise the previous project is recorded as an + /// undo step under `label`, unless it merges with the current merge key (then the redo branch + /// is dropped). An `Err` from `f` propagates with the session unchanged. + fn edit_inner(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + let before = self.project.clone(); + let mut p = (*self.project).clone(); + let mut st = self.state.clone(); + let r = f(&mut p, &mut st)?; + // Layer styles: one Global Light per comp, whichever layer edited it. + effectcraft_project::styles::sync_global_light(&before, &mut p); + // Essential Properties of precomp layers follow their comps' Essential Graphics. + effectcraft_project::essential::sync_project(&before, &mut p); + // Warp Stabilizer analyses made from other frames are cleared (and queued again). + for w in warp::invalidate(&before, &mut p) { + if !self.warp_pending.contains(&w) { + self.warp_pending.push(w); + } + } + // So are 3D Camera Tracker tracks (and solves made with other settings). + for w in camera_track::invalidate(&before, &mut p) { + if !self.camera_pending.contains(&w) { + self.camera_pending.push(w); + } + } + // Roto Brush Input Keys follow the frames; edited instances propagate again. + for r in roto::sync(&before, &mut p) { + if !self.roto_pending.contains(&r) { + self.roto_pending.push(r); + } + } + // Nothing changed (a value set to what it already was): no undo step, not modified. + if p.same_content(&before) { + self.state = st; + return Ok(r); + } + let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key; + if !same { + // Undone steps aren't lost: they stay in the History panel as a branch. + let levels = self.prefs.general.undo_levels.max(1) as usize; + self.history.record(label, before, levels); + } else { + self.history.abandon_redo(&before); + } + self.history.merge_key = merge.map(str::to_string); + self.project = Arc::new(p); + self.state = st; + self.bump(); + Ok(r) + } + + /// Note a change to the on-canvas editor's committed text or session. + /// + /// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated + /// project change must not advance it. + pub(crate) fn note_text_edit_change(&mut self) { + // Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion + // keep the previous value; an unavailable before/after pair then compares equal and + // earns no receipt instead of accepting a stale revision. + if let Some(revision) = commands::text_edit::next_ime_revision() { + self.ime_revision = self.ime_revision.max(revision); + } + } + + pub fn bump(&mut self) { + self.revision += 1; + // Back at the saved state (undo, redo, a history jump or a rolled-back batch). + if Arc::ptr_eq(&self.project, &self.saved_project) { + self.saved_revision = self.revision; + } + self.refresh_disk_salt(false); + self.events.push(Event::ProjectChanged { revision: self.revision }); + } + + /// Salt the disk cache's layer keys with the project's footage files + /// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of + /// every file, every frame of an image sequence included, so it is taken again only when the + /// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache + /// attached), not at every edit: with a few large sequences that cost each keystroke in a + /// text field over half a second (#1926). + pub fn refresh_disk_salt(&mut self, force: bool) -> Option { + use std::hash::{Hash, Hasher}; + if self.disk_cache.is_none() { + self.disk_salt = None; + return None; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (id, it) in &self.project.items { + if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + id.hash(&mut h); + format!("{f:?}").hash(&mut h); + } + } + let footage = h.finish(); + if let Some((seen, salt)) = self.disk_salt + && seen == footage + && !force + { + return Some(salt); + } + let salt = effectcraft_render::disk_cache::footage_salt(&self.project); + self.disk_salt = Some((footage, salt)); + self.layer_cache.set_disk_salt(salt); + Some(salt) + } + + /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes + /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay + /// valid ([`effectcraft_project::Comp::same_pixels`]). + pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> { + if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r { + return Ok(()); + } + Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r; + self.bump(); + Ok(()) + } + + /// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the + /// project before undo, redo or a history jump restored another state. + pub(crate) fn keep_view_settings(&mut self, from: &Project) { + let keep: Vec<(ItemId, effectcraft_project::Resolution)> = + self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect(); + if keep.is_empty() { + return; + } + let p = Arc::make_mut(&mut self.project); + for (id, r) in keep { + if let Some(c) = p.comp_mut(id) { + c.resolution = r; + } + } + } + + /// Undoes one step: the current project goes onto the redo stack, per-comp Resolution is kept + /// as it was, the merge key is cleared, selections are sanitized for the replaced state (the + /// on-canvas editor gets a new IME epoch only when its own document changed) and the session is + /// bumped. Returns `false`, changing nothing, when there is nothing to undo. + pub fn undo(&mut self) -> bool { + let Some((label, p)) = self.history.undo.pop() else { return false }; + let edited = commands::text_edit::active_editor_snapshot(self); + let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); + self.history.redo.push((label, cur)); + self.history.merge_key = None; + self.sanitize_after_replacement(edited.as_ref()); + self.bump(); + true + } + + /// Redoes one step: the current project goes back onto the undo stack, per-comp Resolution is + /// kept, the merge key is cleared, selections are sanitized for the replaced state (a changed + /// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing + /// nothing, when there is nothing to redo. + pub fn redo(&mut self) -> bool { + let Some((label, p)) = self.history.redo.pop() else { return false }; + let edited = commands::text_edit::active_editor_snapshot(self); + let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); + self.history.undo.push((label, cur)); + self.history.merge_key = None; + self.sanitize_after_replacement(edited.as_ref()); + self.bump(); + true + } + + /// Keep selections valid after the project was replaced by another state (undo, redo, a + /// history jump, a rollback): the same layer id can name a different document, so the + /// on-canvas editor gets a fresh IME epoch only when its own document changed. + /// + /// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the + /// state this call is about to replace. Atomic batch rollback is the exception: it has + /// already restored the pre-batch state and passes the snapshot of the failing intermediate + /// state, so a transient restart or component switch inside the batch still rejects reviving + /// the old composition. A restored state that still has the same composition, layer and + /// editor session and shows byte/style-equivalent text (an opacity or other property undo) + /// keeps the editor's owner and selection; a different document, comp, layer or session clamps the + /// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's + /// matching layer id never inherits the old comp's session. `None` means no editor was open + /// when the snapshot was taken; if one is open after the replacement it is treated as an + /// unverified document replacement, so it never inherits an old composition. + pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) { + self.sanitize_state(); + commands::text_edit::after_replacement(self, before); + } + + /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id + /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. + pub fn sanitize_state(&mut self) { + let p = self.project.clone(); + self.state.open_comps.retain(|c| p.comp(*c).is_some()); + self.state.comp_selections.retain(|cid, sel| { + let Some(c) = p.comp(*cid) else { return false }; + let ids: Vec = c.layers.iter().map(|l| l.id).collect(); + sel.layers.retain(|l| ids.contains(l)); + sel.props.retain(|(l, _)| ids.contains(l)); + sel.keys.retain(|k| ids.contains(&k.layer)); + sel.vertices.retain(|v| ids.contains(&v.layer)); + !sel.is_empty() + }); + if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { + self.clear_live_selection(); + // A matching layer id in the fallback comp is not the document being edited. + self.state.text_edit = None; + self.state.active_comp = self.state.open_comps.first().copied(); + if let Some(id) = self.state.active_comp { + self.recall_selection(id); + } + } + if let Some(c) = self.active_comp() { + let ids: Vec = c.layers.iter().map(|l| l.id).collect(); + self.state.selected_layers.retain(|l| ids.contains(l)); + self.state.selected_props.retain(|(l, _)| ids.contains(l)); + self.state.selected_keys.retain(|k| ids.contains(&k.layer)); + self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); + } + if let Some((l, t)) = self.state.current_track + && self.active_comp().and_then(|c| c.layer(l)).and_then(|l| l.tracker(t)).is_none() + { + self.state.current_track = None; + } + self.state.project_selection.retain(|i| p.item(*i).is_some()); + commands::text_edit::sanitize(self); + } + + pub fn is_dirty(&self) -> bool { + self.revision != self.saved_revision + } + + /// The project as it is now is the saved one (after opening or saving it). + pub fn mark_saved(&mut self) { + self.saved_project = self.project.clone(); + self.saved_revision = self.revision; + } + + /// The project has changes its file doesn't (a session restored with unsaved changes): it + /// stays modified until it is saved, whatever is undone. + pub fn mark_unsaved(&mut self) { + self.saved_project = Arc::new(Project::default()); + self.bump(); + } + + pub fn active_comp(&self) -> Option<&Comp> { + self.project.comp(self.state.active_comp?) + } + + /// [`Session::active_comp`] as a shared snapshot (an `Arc` clone, not a deep copy). + pub fn active_comp_arc(&self) -> Option> { + self.project.comp_arc(self.state.active_comp?) + } + + pub fn active_comp_id(&self) -> Option { + self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) + } + + /// Current time of a comp. + pub fn time_of(&self, comp: ItemId) -> Tick { + self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO) + } + + /// Current time of the active comp. + pub fn time(&self) -> Tick { + self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) + } + + /// Move the active comp's current time. + /// + /// Time is an external boundary for the on-canvas text editor too: an animated Source Text + /// can show a different document at another time. The document is snapshotted only while an + /// editor is open, so playback with no text editor does no text copying. + pub fn set_time(&mut self, t: Tick) { + if let Some(c) = self.state.active_comp { + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); + self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + // Direct call: this `set_time` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); + } + commands::text_edit::after_external_change(self, edited.as_ref()); + } + } + + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of + /// `comp` moves the current time of the comps nested in it (through their precomp layers' + /// timing) and of the comps it is nested in, so every related viewer shows the same moment. + pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { + // Moving a related comp can move the active one, so the editor's document is checked + // here too; the snapshot is skipped entirely when no text editor is open. + let edited = commands::text_edit::active_editor_snapshot(self); + self.sync_related_times_inner(comp, t); + commands::text_edit::after_external_change(self, edited.as_ref()); + } + + /// Sets the time of every comp related to `comp` through precomp layers: down into the comps + /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps + /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to + /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. + fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { + let mut seen = std::collections::BTreeSet::from([comp]); + let mut todo = vec![(comp, t)]; + let mut found = vec![]; + while let Some((cid, ct)) = todo.pop() { + if cid != comp { + found.push((cid, ct)); + } + let Some(c) = self.project.comp(cid) else { continue }; + // Down: the comps this one nests. + for l in &c.layers { + if let effectcraft_project::LayerSource::Comp { item } = &l.source + && seen.insert(*item) + { + todo.push((*item, l.layer_time(ct))); + } + } + // Up: the comps nesting this one. + for iid in self.project.items.keys() { + if let Some(pc) = self.project.comp(*iid) + && let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid)) + && seen.insert(*iid) + { + todo.push((*iid, l.comp_time(ct))); + } + } + } + for (cid, ct) in found { + if let Some(c) = self.project.comp(cid) { + let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + self.state.times.insert(cid, ct); + } + } + } + + /// Make `id` the active comp, parking the previous comp's selection and restoring this one's. + pub fn activate_comp(&mut self, id: ItemId) { + if self.project.comp(id).is_none() || self.state.active_comp == Some(id) { + return; + } + self.remember_selection(); + self.state.active_comp = Some(id); + self.recall_selection(id); + self.state.current_track = None; + // A text-edit session belongs to the comp that owned it. Switching comps ends it; even + // a same-numbered layer in the new comp must not inherit the old IME composition. + self.state.text_edit = None; + } + + /// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`]. + pub fn activate_comp_none(&mut self) { + self.remember_selection(); + self.state.active_comp = None; + self.clear_live_selection(); + self.state.current_track = None; + self.state.text_edit = None; + } + + fn remember_selection(&mut self) { + let Some(id) = self.state.active_comp else { return }; + let sel = CompSelection { + layers: self.state.selected_layers.clone(), + props: self.state.selected_props.clone(), + keys: self.state.selected_keys.clone(), + vertices: self.state.selected_vertices.clone(), + }; + if sel.is_empty() { + self.state.comp_selections.remove(&id); + } else { + self.state.comp_selections.insert(id, sel); + } + } + + fn recall_selection(&mut self, id: ItemId) { + match self.state.comp_selections.remove(&id) { + Some(sel) => { + self.state.selected_layers = sel.layers; + self.state.selected_props = sel.props; + self.state.selected_keys = sel.keys; + self.state.selected_vertices = sel.vertices; + } + None => self.clear_live_selection(), + } + } + + fn clear_live_selection(&mut self) { + self.state.selected_layers.clear(); + self.state.selected_props.clear(); + self.state.selected_keys.clear(); + self.state.selected_vertices.clear(); + } + + /// Open a comp in the viewer/timeline and make it active. + pub fn open_comp(&mut self, id: ItemId) { + if self.project.comp(id).is_none() { + return; + } + if !self.state.open_comps.contains(&id) { + self.state.open_comps.push(id); + } + if self.state.active_comp != Some(id) { + let previous = self.state.active_comp; + self.state.recent_comps.retain(|c| *c != id && Some(*c) != previous); + if let Some(previous) = previous { + self.state.recent_comps.insert(0, previous); + self.state.recent_comps.truncate(32); + } + } + self.activate_comp(id); + self.events.push(Event::OpenComp(id)); + } + + /// The open compositions and their current times, as a project file records them + /// ([`effectcraft_project::SavedView`]). + pub fn saved_view(&self) -> effectcraft_project::SavedView { + let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect(); + effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times } + } + + /// Reopen the compositions a project file was saved with (#748), each at its saved current + /// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays + /// on the comp [`Session::replace_project`] opened (its first), as files from before the view + /// was saved do. + pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) { + for (id, t) in &view.comp_times { + if let Some(comp) = self.project.comp(*id) { + let t = comp_frame_at(comp, *t); + self.state.times.insert(*id, t); + } + } + let mut open: Vec = Vec::new(); + for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) { + if !open.contains(&id) { + open.push(id); + } + } + let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return }; + self.state.open_comps = open; + self.open_comp(active); + } + + pub fn toast(&mut self, msg: impl Into) { + self.events.push(Event::Toast { message: msg.into(), error: false }); + } + + pub fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// The 3D view camera of a comp's viewer (None = the active camera). + pub fn view_camera(&self, comp: ItemId) -> Option { + let c = self.project.comp(comp)?; + if !c.has_3d() { + return None; + } + self.state.views3d.get(&comp)?.override_camera(c.width as f64, c.height as f64) + } + + /// Render a comp frame with the session's footage + expression hosts. + pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + self.renderer(opts).comp_frame(comp, t) + } + + /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and + /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. + pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { + let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; + let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); + r.expr = self.expr.as_deref(); + r.cache = Some(&self.layer_cache); + r.accel = self.accel.as_deref(); + r + } + + /// Resolve a comp reference (item id number or name; `None`/null = the active comp). + pub fn resolve_comp(&self, comp: Option<&Value>) -> Result { + let p = match comp { + Some(v) if !v.is_null() => serde_json::json!({ "comp": v }), + _ => serde_json::json!({}), + }; + commands::comp_id(self, &p) + } + + /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled + /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba). + pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec)> { + let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); + Ok((img.width, img.height, img.to_rgba8_over(c.background))) + } + + /// A Project panel thumbnail of an item (a comp at `t`, footage at its first frame), longest + /// side at most `max_side`, as a job that owns everything it reads: frontends run it off the + /// UI thread (a big comp takes far longer than a frame). `None` for items without pixels. + pub fn thumbnail_job(&self, item: ItemId, t: Tick, max_side: u32) -> Option { + let it = self.project.item(item)?; + let footage = self.footage.clone(); + match &it.kind { + effectcraft_project::ItemKind::Comp(c) => { + let (project, expr, cache) = (self.project.clone(), self.expr.clone(), self.layer_cache.clone()); + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let opts = RenderOpts { + scale, + nested_switches: self.prefs.general.switches_affect_nested_comps, + draft_shadows: self.prefs.three_d.realtime_shadows, + ..Default::default() + }; + let bg = c.background; + Some(Box::new(move || { + let mut r = Renderer::new(&project, footage.as_ref(), opts); + r.expr = expr.as_deref(); + r.cache = Some(&cache); + let img = r.comp_frame(item, t); + Some((img.width, img.height, img.to_rgba8_over(bg))) + })) + } + effectcraft_project::ItemKind::Footage(f) if f.has_video => { + let f = f.clone(); + Some(Box::new(move || { + let img = footage.frame(item, &f, Tick(0))?; + // Point-sample down to at most `max_side` on the long side. + let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let full = img.to_rgba8(); + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let i = (((y * step) * img.width + x * step) * 4) as usize; + px.extend_from_slice(&full[i..i + 4]); + } + } + Some((w, h, px)) + })) + } + _ => None, + } + } + + /// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a + /// render with RGB + Alpha channels writes) instead of compositing over the background. + pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec)> { + if !transparent { + return self.render_rgba8(comp, t, max_side); + } + let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); + Ok((img.width, img.height, img.to_rgba8())) + } + + /// Make the media layer and the layer cache let go of what they decoded from `footage`'s + /// files (it keys frames by path, so an overwritten file would keep showing its old pixels). + pub(crate) fn forget_decoded(&self, footage: &effectcraft_project::Footage) { + for p in std::iter::once(&footage.path).chain(&footage.sequence) { + self.footage.forget(p); + } + self.layer_cache.clear(); + } + + /// Replace the whole project (open/new), resetting history and state. + pub fn replace_project(&mut self, mut p: Project, path: Option) { + if self.stop_render() + && let Some(mut job) = self.render_job.take() + { + // Let the worker notice the cancel; its updates refer to the old project. + job.wait(); + } + self.render_job = None; + self.stored_paths = Default::default(); + self.stop_track(); + self.track_job = None; + self.stop_mask_track(); + self.mask_job = None; + self.stop_warp(); + self.warp_job = None; + self.warp_pending.clear(); + self.stop_camera(); + self.camera_job = None; + self.camera_pending.clear(); + self.stop_roto(); + self.roto_job = None; + self.roto_pending.clear(); + self.drop_tasks(); + self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1); + p.fix_next_id(); + upgrade_effects(&mut p); + // The media layer keys decoded frames by path: files of the new project may have changed + // since an earlier project decoded them (#1070). + for it in p.items.values() { + if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + self.forget_decoded(f); + } + } + self.layer_cache.clear(); + self.project = Arc::new(p); + // Its files may have changed since they were last stamped. + self.disk_salt = None; + self.history = History::default(); + self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; + self.path = path; + self.bump(); + self.mark_saved(); + let first = self.project.comps().next().map(|(id, _)| *id); + if let Some(c) = first { + self.open_comp(c); + } + } +} + +/// Bring effect instances saved by earlier versions up to date with the effect registry +/// (renamed / regrouped / added parameters, reordered popups; see +/// [`effectcraft_effects::migrate`]), and a project from before +/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space +/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. +pub fn upgrade_effects(p: &mut Project) { + let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE; + // Effect bounds per layer: (size, top-left in layer coordinates). + let mut bounds = std::collections::HashMap::new(); + for (cid, c) in p.comps() { + for l in &c.layers { + bounds.insert((*cid, l.id), effectcraft_render::effect_bounds(p, c, l)); + } + } + let mut next = p.next_id; + for (cid, it) in p.items.iter_mut() { + let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue }; + for l in &mut std::sync::Arc::make_mut(c).layers { + let (size, origin) = bounds.get(&(*cid, l.id)).copied().unwrap_or(([100.0, 100.0], [0.0; 2])); + // Effect space starts at the bounds' top-left: positions move by minus that corner. + // Adjustment layers run their effects on the comp below as it is: nothing moves. + let shift = |o: [f64; 2]| [-o[0], -o[1]]; + let d = if l.switches.adjustment { [0.0; 2] } else { shift(origin) }; + let clone_d = |src: Option| match src { + Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)), + None => d, + }; + let Some(fx) = l.props.sub_mut("effects") else { continue }; + for n in &mut fx.children { + let effectcraft_project::Node::Group(g) = n else { continue }; + let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue }; + if let Some(spec) = effectcraft_effects::find(effect) { + effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size); + if to_effect_space { + effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d); + } + } + } + } + } + p.next_id = next; + p.schema = effectcraft_project::SCHEMA_VERSION; +}