mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Edit > Copy with a shape group (or a path, fill, stroke or path operation) selected in a shape layer's Contents copied the whole layer, and Paste made a new layer. As in After Effects, Copy now copies the selected items, Paste puts them into each selected shape layer's Contents (above its selected item, in that item's group, else on top, with names kept unique there: "Rectangle 2"), and Cut removes them, so Cut + Paste moves them. Pasting with no shape layer selected is an error rather than a new layer. Copying anything else clears the shape item clipboard, as it does the other clipboards. Test: shape_contents_copy_cut_and_paste_between_shape_layers, ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1115 lines
45 KiB
Rust
1115 lines
45 KiB
Rust
//! 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<Project>` 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 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;
|
|
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<T> = std::result::Result<T, EngineError>;
|
|
|
|
/// Host services (file access) injected by the frontend.
|
|
pub trait Services: Send + Sync {
|
|
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
|
|
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()
|
|
}
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// Native filesystem.
|
|
pub struct FsServices;
|
|
impl Services for FsServices {
|
|
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
|
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 a file and return footage metadata.
|
|
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
|
|
/// 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]) {}
|
|
}
|
|
|
|
/// 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<KeyRef>,
|
|
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<effectcraft_keyframe::Keyframe>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Editing state that commands depend on (headless-relevant, serde for agents).
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct EditorState {
|
|
pub active_comp: Option<ItemId>,
|
|
/// Open compositions (viewer/timeline tabs), in tab order.
|
|
pub open_comps: Vec<ItemId>,
|
|
/// Current time per comp (the CTI).
|
|
pub times: std::collections::BTreeMap<ItemId, Tick>,
|
|
pub selected_layers: Vec<LayerId>,
|
|
/// Selected properties / groups (by uid) of selected layers.
|
|
pub selected_props: Vec<(LayerId, Uid)>,
|
|
pub selected_keys: Vec<KeyRef>,
|
|
pub project_selection: Vec<ItemId>,
|
|
/// Layer clipboard (serialized layers) and keyframe clipboard.
|
|
#[serde(skip)]
|
|
pub clipboard: Vec<Layer>,
|
|
/// Copied keyframes (times relative to the earliest copied key).
|
|
#[serde(skip)]
|
|
pub key_clipboard: Vec<KeyClip>,
|
|
/// The last copy was keyframes (Paste pastes keys at the CTI).
|
|
#[serde(skip)]
|
|
pub clip_is_keys: bool,
|
|
/// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the
|
|
/// selected layers.
|
|
#[serde(skip)]
|
|
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
|
|
/// 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<effectcraft_project::PropGroup>,
|
|
/// Selected mask vertices (viewer Selection tool / pen).
|
|
#[serde(default)]
|
|
pub selected_vertices: Vec<VertexRef>,
|
|
pub snapping: bool,
|
|
/// Last applied effect id (Effect ▸ last effect).
|
|
pub last_effect: Option<String>,
|
|
/// 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<LinkClip>,
|
|
/// File ▸ Interpret Footage ▸ Remember Interpretation.
|
|
#[serde(skip)]
|
|
pub interpretation: Option<effectcraft_project::Footage>,
|
|
/// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras).
|
|
#[serde(default)]
|
|
pub views3d: std::collections::BTreeMap<ItemId, effectcraft_render::three_d::Views3D>,
|
|
/// 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<u32>,
|
|
/// 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,
|
|
/// 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<commands::text_edit::TextEdit>,
|
|
/// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only.
|
|
#[serde(skip)]
|
|
pub text_clipboard: Option<effectcraft_keyframe::TextDoc>,
|
|
/// 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<ItemId>,
|
|
/// 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<String>,
|
|
/// The Footage panel: the footage shown, its time and In/Out marks (source time).
|
|
#[serde(default)]
|
|
pub footage_panel: Option<commands::footage_panel::FootageView>,
|
|
/// 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<commands::viewer_cmds::LockedViewer>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
pub struct Session {
|
|
pub project: Arc<Project>,
|
|
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<Project>,
|
|
pub path: Option<String>,
|
|
pub state: EditorState,
|
|
pub services: Arc<dyn Services>,
|
|
pub footage: Arc<dyn FootageSource>,
|
|
pub expr: Option<Arc<dyn ExprHost>>,
|
|
/// 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<Arc<dyn effectcraft_render::Accelerator>>,
|
|
/// Expression syntax checker (set by the host that links the expression engine).
|
|
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
|
|
pub importer: Option<Arc<dyn Importer>>,
|
|
/// Render Queue encoder (the export layer); `None` = export unavailable.
|
|
pub exporter: Option<Arc<dyn Exporter>>,
|
|
/// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full).
|
|
pub storage_quota: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
|
|
/// The running (or finished, not yet polled) render.
|
|
pub render_job: Option<render_queue::RenderJob>,
|
|
/// The running (or finished, not yet polled) track analysis.
|
|
pub track_job: Option<tracking::TrackJob>,
|
|
/// The running (or finished, not yet polled) mask track.
|
|
pub mask_job: Option<mask_track::MaskTrackJob>,
|
|
/// The running (or finished, not yet polled) Warp Stabilizer analysis.
|
|
pub warp_job: Option<warp::WarpJob>,
|
|
/// 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<camera_track::CameraJob>,
|
|
/// 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<roto::RotoJob>,
|
|
/// 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<std::path::PathBuf>,
|
|
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
|
|
pub roto_pending: Vec<(ItemId, LayerId, Uid)>,
|
|
pub events: Vec<Event>,
|
|
/// 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<LayerCache>,
|
|
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
|
|
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
|
|
/// 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<Arc<dyn config::ConfigStore>>,
|
|
/// Keyboard shortcut presets.
|
|
pub keymaps: shortcuts::Keymaps,
|
|
/// Frontend-only commands offered for binding.
|
|
pub ui_commands: Vec<shortcuts::UiCommand>,
|
|
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
|
|
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
|
/// Auto-save bookkeeping.
|
|
pub autosave: autosave::AutoSaveState,
|
|
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
|
pub snapshot: Option<viewer::Snapshot>,
|
|
/// 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<ScriptRunner>,
|
|
/// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with
|
|
/// its runtime): `effect.plugins.load`.
|
|
pub plugin_loader: Option<PluginLoader>,
|
|
/// 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<sysinfo::SysMemory>,
|
|
/// The keyframe drag in progress (`keys.move` with a merge key).
|
|
pub key_move: Option<KeyMove>,
|
|
/// 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<bool>,
|
|
/// 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<Arc<dyn offload::Offload>>,
|
|
/// Analyses running in the [`Session::offload`] worker.
|
|
pub offloaded: Vec<offload::OffloadedJob>,
|
|
/// What the Media Browser browses (`None`: the local file system where there is one).
|
|
pub browser: Option<Arc<dyn media_browser::Browser>>,
|
|
/// The browser's storage (the web app; Settings ▸ Disk ▸ Browser Storage, `storage.*`).
|
|
pub storage: Option<Arc<dyn storage::StorageHost>>,
|
|
/// 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<String>)>,
|
|
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
|
|
pub tasks: Vec<jobs::Task>,
|
|
/// Finished tasks, newest last (Progress panel, `jobs.list`).
|
|
pub job_log: Vec<jobs::JobRecord>,
|
|
pub next_task_id: u64,
|
|
/// The running Learn tutorial (Home ▸ Learn), see [`learn`].
|
|
pub learn: Option<learn::Progress>,
|
|
/// 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,
|
|
}
|
|
|
|
/// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8).
|
|
pub type ThumbnailJob = Box<dyn FnOnce() -> Option<(u32, u32, Vec<u8>)> + 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<Value, String>;
|
|
|
|
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,
|
|
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(),
|
|
ui_commands: vec![],
|
|
shortcut_table: std::sync::OnceLock::new(),
|
|
autosave: autosave::AutoSaveState::default(),
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
pub fn new() -> Session {
|
|
Session::default()
|
|
}
|
|
|
|
/// Run a command by id.
|
|
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
|
|
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<Value> {
|
|
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
|
commands::check_params(spec, ¶ms)?;
|
|
self.execute(id, params)
|
|
}
|
|
|
|
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<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
|
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 });
|
|
}
|
|
|
|
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.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.history.undo.push((label, cur));
|
|
self.history.merge_key = None;
|
|
self.sanitize_state();
|
|
self.bump();
|
|
true
|
|
}
|
|
|
|
/// Drop selections that no longer exist.
|
|
pub fn sanitize_state(&mut self) {
|
|
let p = self.project.clone();
|
|
self.state.open_comps.retain(|c| p.comp(*c).is_some());
|
|
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
|
|
self.state.active_comp = self.state.open_comps.first().copied();
|
|
}
|
|
if let Some(c) = self.active_comp() {
|
|
let ids: Vec<LayerId> = 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<std::sync::Arc<Comp>> {
|
|
self.project.comp_arc(self.state.active_comp?)
|
|
}
|
|
pub fn active_comp_id(&self) -> Option<ItemId> {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
self.state.active_comp = Some(id);
|
|
self.state.selected_layers.clear();
|
|
self.state.selected_props.clear();
|
|
self.state.selected_keys.clear();
|
|
self.state.selected_vertices.clear();
|
|
self.state.current_track = None;
|
|
}
|
|
self.events.push(Event::OpenComp(id));
|
|
}
|
|
|
|
pub fn toast(&mut self, msg: impl Into<String>) {
|
|
self.events.push(Event::Toast { message: msg.into(), error: false });
|
|
}
|
|
|
|
pub fn drain_events(&mut self) -> Vec<Event> {
|
|
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<effectcraft_render::three_d::CameraState> {
|
|
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<ItemId> {
|
|
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<u8>)> {
|
|
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<ThumbnailJob> {
|
|
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<u8>)> {
|
|
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()))
|
|
}
|
|
|
|
/// Replace the whole project (open/new), resetting history and state.
|
|
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
|
|
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();
|
|
p.fix_next_id();
|
|
upgrade_effects(&mut p);
|
|
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`]).
|
|
pub fn upgrade_effects(p: &mut Project) {
|
|
let mut sizes = std::collections::HashMap::new();
|
|
for (cid, c) in p.comps() {
|
|
for l in &c.layers {
|
|
let (w, h) = effectcraft_render::source_size(p, l);
|
|
sizes.insert((*cid, l.id), if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] });
|
|
}
|
|
}
|
|
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 = sizes.get(&(*cid, l.id)).copied().unwrap_or([100.0, 100.0]);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
p.next_id = next;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod rq_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
mod tests_3d;
|
|
#[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_keylight;
|
|
#[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_rig3d;
|
|
#[cfg(test)]
|
|
mod tests_settings;
|
|
#[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()
|
|
}
|
|
|
|
pub fn text_families() -> Vec<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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,
|
|
}
|
|
|
|
/// 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<Vec<[f32; 2]>> {
|
|
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.
|
|
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
|
|
let all = text_families();
|
|
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
|
|
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
|
|
let mut out: Vec<FontRow> = 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 });
|
|
}
|
|
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;
|