//! The EffectCraft engine façade. //! //! Every user-visible action is a command with a stable id (`layer.newSolid`, `prop.set`, //! `keys.easyEase`…) and JSON parameters, dispatched through [`Session::execute`]. The egui UI, //! the CLI, the control channel and the MCP server all use this one entry point; that is what //! makes the UI swappable and the whole app agent-drivable. //! //! The project is an `Arc` edited copy-on-write; undo keeps whole-project snapshots //! (compositions are `Arc`s, so untouched comps are shared). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod autosave; pub mod camera_track; pub mod commands; pub mod config; pub mod demo; pub mod footage_check; pub mod footage_reload; pub mod guard; pub mod history; pub mod jobs; pub mod learn; pub mod links; pub mod logging; pub mod mask_track; pub mod media_browser; pub mod media_cache; pub mod menus; pub mod models; pub mod offload; pub mod perf; pub mod prefs; pub mod preview; pub mod psd_import; pub mod remote; pub mod render_queue; pub mod roto; pub mod scriptui; pub mod sequence; mod session_settings; pub mod shortcuts; pub mod storage; pub mod sysinfo; pub mod templates; pub mod tracking; pub mod vector; pub mod viewer; pub mod warp; pub mod xml_project; use std::sync::Arc; use effectcraft_project::{Comp, ItemId, Layer, LayerId, Project, Uid}; use effectcraft_raster::Image; use effectcraft_render::{ExprHost, FootageSource, LayerCache, NoFootage, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::Value; pub use commands::{CommandSpec, command_specs, find as find_command}; pub use effectcraft_color as color; pub use effectcraft_effects as effects; pub use effectcraft_geom as geom; pub use effectcraft_keyframe as keyframe; pub use effectcraft_project as project; pub use effectcraft_raster as raster; pub use effectcraft_render as render; pub use effectcraft_segment as segment; pub use effectcraft_text as text; pub use effectcraft_time as time; pub use effectcraft_track as track; pub use history::{Branch, History, HistoryNode}; pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; #[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; /// 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 } /// 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 struct Session { pub project: Arc, pub history: History, pub revision: u64, pub saved_revision: u64, /// The project as last opened or saved: the project is unmodified while it is this very /// snapshot, so undoing back to the saved state clears the modified mark. pub saved_project: Arc, pub path: Option, pub state: EditorState, pub services: Arc, pub footage: Arc, pub expr: Option>, /// GPU compositor (Mercury GPU Acceleration), when the frontend has one: used by renders /// that ask for [`effectcraft_render::Backend::Gpu`]/`Auto`, and by Render Queue exports /// when the project's renderer is the GPU. pub accel: Option>, /// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a /// headless start without `--gpu` or the compositor's setup error. pub accel_note: Option, /// The graphics adapter and backend the window draws with (`NVIDIA GeForce RTX 4070 /// (Vulkan)`), as the host reports it: the System Compatibility Report shows it. pub window_adapter: Option, /// Expression syntax checker (set by the host that links the expression engine). pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, /// Render Queue encoder (the export layer); `None` = export unavailable. pub exporter: Option>, /// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full). pub storage_quota: Option>, /// The running (or finished, not yet polled) render. pub render_job: Option, /// The running (or finished, not yet polled) track analysis. pub track_job: Option, /// The running (or finished, not yet polled) mask track. pub mask_job: Option, /// The running (or finished, not yet polled) Warp Stabilizer analysis. pub warp_job: Option, /// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid). pub warp_pending: Vec<(ItemId, LayerId, Uid)>, /// The running (or finished, not yet polled) 3D Camera Tracker analysis. pub camera_job: Option, /// 3D Camera Trackers waiting for (re-)analysis: (comp, layer, effect uid). pub camera_pending: Vec<(ItemId, LayerId, Uid)>, /// The running (or finished, not yet polled) Roto Brush propagation / Freeze. pub roto_job: Option, /// Trained models for Roto Brush and face tracking (Settings ▸ Roto Brush / Face Tracking). pub models: models::Models, /// Where installed models live (hosts set it; else `models` next to the settings). pub models_dir: Option, /// Roto Brush instances edited since their last propagation: (comp, layer, effect uid). pub roto_pending: Vec<(ItemId, LayerId, Uid)>, pub events: Vec, /// Commands executed: (id, params). pub journal: Vec<(String, Value)>, /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). pub layer_cache: Arc, /// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled. pub disk_cache: Option>, /// Settings (Preferences). pub prefs: prefs::Prefs, /// Bumped whenever settings change (frontends re-apply theme, labels…). pub prefs_revision: u64, /// Where settings, shortcut presets and the crash-recovery sentinel are stored (`None` = /// nothing persists). pub config: Option>, /// Keyboard shortcut presets. pub keymaps: shortcuts::Keymaps, /// Scripts' `app.settings`, kept in the settings store (see /// [`commands::scripts::SCRIPT_SETTINGS_FILE`]). pub script_settings: commands::scripts::ScriptSettings, /// Frontend-only commands offered for binding. pub ui_commands: Vec, /// Cache of the resolved active preset (read it with [`Session::shortcuts`]). pub shortcut_table: std::sync::OnceLock, /// Auto-save bookkeeping. pub autosave: autosave::AutoSaveState, /// Host-owned isolation for all auto-saves (e.g. one MCP session). Independent of settings /// and project bookkeeping, so opening/saving a project or changing preferences keeps it. pub autosave_folder_override: Option, /// The viewer snapshot (Take Snapshot / Show Snapshot). pub snapshot: Option, /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): /// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console. pub script: Option, /// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with /// its runtime): `effect.plugins.load`. pub plugin_loader: Option, /// ScriptUI windows and panels opened by scripts (see [`scriptui`]). pub script_ui: scriptui::ScriptUi, /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown /// (headless sessions don't query it, see [`Session::memory_tick`]). pub sys_memory: Option, /// The keyframe drag in progress (`keys.move` with a merge key). pub key_move: Option, /// Reads the system's memory in the background for [`Session::memory_tick`]. pub memory_watch: sysinfo::MemoryWatch, /// Switches Affect Nested Comps as last applied (a change empties the layer cache). pub applied_nested_switches: Option, /// Runs renders and analyses off the UI thread where there are no threads (the web app's /// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32). pub offload: Option>, /// Analyses running in the [`Session::offload`] worker. pub offloaded: Vec, /// What the Media Browser browses (`None`: the local file system where there is one). pub browser: Option>, /// The browser's storage (the web app; Settings ▸ Disk ▸ Browser Storage, `storage.*`). pub storage: Option>, /// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one /// (`wait: true` over the control channel in the browser) learns its outcome. pub offload_log: Vec<(offload::JobKind, Option)>, /// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`]. pub tasks: Vec, /// Finished tasks, newest last (Progress panel, `jobs.list`). pub job_log: Vec, pub next_task_id: u64, /// The running Learn tutorial (Home ▸ Learn), see [`learn`]. pub learn: Option, /// Check footage in the background after File ▸ Open ([`footage_check`]): frontends with an /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one /// is, settings that guard what scripts may do can't change (see /// [`prefs::Scripting::same_permissions`]). pub script_calls: ScriptCalls, } /// The count behind [`Session::running_for_script`]; only the engine changes it. #[derive(Clone, Copy, Debug, Default)] pub struct ScriptCalls(u32); /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). pub type ThumbnailJob = Box Option<(u32, u32, Vec)> + Send>; /// A script to run (see [`Session::script`]). #[derive(Clone, Copy, Debug)] pub struct ScriptRequest<'a> { pub code: &'a str, /// File name shown in errors and `$.fileName` (`console` for the Script Console). pub name: &'a str, /// Run in the Script Console's persistent context (variables survive between runs). pub console: bool, } /// Runs a script against the session and reports `{ok, result, output, error: {message, line, /// column, file} | null}`. pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; /// Loads one WebAssembly effect plug-in (`bytes`, `source` path) into the effect registry and /// describes it (`{id, name, category, version, api, params}`). pub type PluginLoader = fn(&[u8], &str) -> std::result::Result; impl Default for Session { fn default() -> Self { let project = Arc::new(Project::default()); Session { saved_project: project.clone(), project, history: History::default(), revision: 0, saved_revision: 0, path: None, state: EditorState { snapping: true, view_layout: 1, ..Default::default() }, services: Arc::new(FsServices), footage: Arc::new(NoFootage), expr: None, accel: None, accel_note: None, window_adapter: None, expr_check: None, importer: None, exporter: None, storage_quota: None, render_job: None, track_job: None, mask_job: None, warp_job: None, warp_pending: vec![], camera_job: None, camera_pending: vec![], roto_job: None, models: Default::default(), models_dir: None, roto_pending: vec![], events: vec![], journal: vec![], layer_cache: Arc::new(LayerCache::default()), disk_cache: None, prefs: prefs::Prefs::default(), prefs_revision: 0, config: None, keymaps: shortcuts::Keymaps::default(), script_settings: Default::default(), ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), autosave_folder_override: None, snapshot: None, script: None, plugin_loader: None, script_ui: scriptui::ScriptUi::default(), sys_memory: None, key_move: None, memory_watch: Default::default(), applied_nested_switches: None, offload: None, offloaded: vec![], offload_log: vec![], browser: None, storage: None, tasks: vec![], job_log: vec![], next_task_id: 1, learn: None, check_footage_on_open: false, footage_stamps: Default::default(), footage_scan: None, script_calls: ScriptCalls::default(), } } } 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 } 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. pub fn edit(&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) } 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; } if self.disk_cache.is_some() { self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project)); } self.events.push(Event::ProjectChanged { revision: self.revision }); } /// 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; } } } pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; 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_state(); self.bump(); true } pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; 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_state(); self.bump(); true } /// 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(); 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) } pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { let comp = self.project.comp(c); let t = match comp { Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), None => t, }; self.state.times.insert(c, t); if self.prefs.general.sync_time_related_items { self.sync_related_times(c, t); } } } /// 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) { 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; } /// 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; } 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, as a project file records them ([`effectcraft_project::SavedView`]). pub fn saved_view(&self) -> effectcraft_project::SavedView { effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() } } /// Reopen the compositions a project file was saved with (#748). 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) { 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 { 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.comp_frame(comp, t) } /// 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.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); 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; #[cfg(test)] mod tests_3d; #[cfg(test)] mod tests_anim_presets; #[cfg(test)] mod tests_anim_tools; #[cfg(test)] mod tests_camera_track; #[cfg(test)] mod tests_codec_options; #[cfg(test)] mod tests_color_view; #[cfg(test)] mod tests_disk_cache; #[cfg(test)] mod tests_effects; #[cfg(test)] mod tests_essential; #[cfg(test)] mod tests_essential_more; #[cfg(test)] mod tests_face; #[cfg(test)] mod tests_fidelity; #[cfg(test)] mod tests_frame_export; #[cfg(test)] mod tests_fuzz_open; #[cfg(test)] mod tests_import_project; #[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] mod tests_keylight; #[cfg(test)] mod tests_limits; #[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_m137; #[cfg(test)] mod tests_m145; #[cfg(test)] mod tests_markers; #[cfg(test)] mod tests_mask_warp; #[cfg(test)] mod tests_menu_cmds; #[cfg(test)] mod tests_model3d; #[cfg(test)] mod tests_motion_blur_depth; #[cfg(test)] mod tests_project_items; #[cfg(test)] mod tests_proxy; #[cfg(test)] mod tests_quick_offset; #[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; #[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_shortcut_cmds; #[cfg(test)] mod tests_stubs; #[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_tail; #[cfg(test)] mod tests_text; #[cfg(test)] mod tests_text_edit; #[cfg(test)] mod tests_timeline; #[cfg(test)] mod tests_timeline_interchange; #[cfg(test)] mod tests_track; #[cfg(test)] mod tests_vector_import; #[cfg(test)] mod tests_viewer; /// 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 } #[cfg(test)] mod tests_history; #[cfg(test)] mod tests_models; #[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_panels; #[cfg(test)] mod tests_roto; #[cfg(test)] mod tests_key_time;