Files
effectcraft/crates/engine/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 8f966a99f7 M9.4: layer styles render + commands
Layer Styles (Drop Shadow, Inner Shadow, Outer/Inner Glow, Bevel and Emboss, Satin,
Color/Gradient Overlay, Stroke) and Blending Options (Global Light, Fill Opacity, R/G/B,
Knockout, Blend Interior Styles as Group) as a layerStyles property group with AE names,
order and defaults. Rendered in layer space after effects (exterior passes composite with
their own blend modes below the body), cached per pass, scaled with render resolution.
Commands layer.style.* (menu Layer > Layer Styles), Global Light shared per comp, timeline
eye switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:00:00 -04:00

432 lines
16 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).
pub mod commands;
pub mod demo;
pub mod links;
pub mod render_queue;
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_render as render;
pub use effectcraft_time as time;
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,
#[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<()>;
}
/// 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<()> {
let p = std::path::Path::new(path);
let tmp = p.with_extension("ecproj.tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, p)
}
}
/// 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>;
}
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
pub undo: Vec<(String, Arc<Project>)>,
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<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,
}
/// 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,
/// 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 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>,
}
/// 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),
}
pub struct Session {
pub project: Arc<Project>,
pub history: History,
pub revision: u64,
pub saved_revision: u64,
pub path: Option<String>,
pub state: EditorState,
pub services: Arc<dyn Services>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
/// 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>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
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>,
}
impl Default for Session {
fn default() -> Self {
Session {
project: Arc::new(Project::default()),
history: History::default(),
revision: 0,
saved_revision: 0,
path: None,
state: EditorState { snapping: true, ..Default::default() },
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
expr_check: None,
importer: None,
exporter: None,
render_job: None,
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
}
}
}
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();
if !explicit {
return Err(EngineError::Disabled(id.to_string(), why));
}
}
let r = (spec.run)(self, &params)?;
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, &params)?;
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);
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
if self.history.undo.len() > 500 {
self.history.undo.remove(0);
}
}
self.history.merge_key = merge.map(str::to_string);
self.history.redo.clear();
self.project = Arc::new(p);
self.state = st;
self.bump();
Ok(r)
}
pub fn bump(&mut self) {
self.revision += 1;
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));
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
}
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
pub fn active_comp(&self) -> Option<&Comp> {
self.project.comp(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 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);
}
}
/// 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.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 mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
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, ..Default::default() });
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
}
/// 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;
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
self.state = EditorState { snapping: true, ..Default::default() };
self.path = path;
self.bump();
self.saved_revision = self.revision;
let first = self.project.comps().next().map(|(id, _)| *id);
if let Some(c) = first {
self.open_comp(c);
}
}
}
#[cfg(test)]
mod rq_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_timeline;
/// Font families available to text layers (bundled + scanned system fonts).
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}