Files
filmcraft/crates/engine/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 4918555906 M10.2: graphic clips — text and shape layers in the project model, vector renderer (CPU + GPU plan), graphics.* commands
- project: ItemKind::Graphic (transparent canvas source, hidden from the Project panel,
  unlimited duration); graphic layers are hidden effect instances graphic_text /
  graphic_shape on the clip (serde for free, keyframes on every animatable property,
  Source Text holds), with appearance (fill, two strokes outer/centre/inner, background
  box with radius, drop shadow) and transform (position, anchor, scale, rotation,
  opacity); eval_layer gives a plain LayerSpec. No schema migration: all additive.
- render: graphic_clip rasterises each layer straight to the output transform (crisp at
  any Motion scale/rotation), caches layer rasters, composes a tight image for the GPU
  plan; standard effects on a graphic run at source resolution first.
- engine: graphics.newText (Cmd+T) / newShape (new clip above the footage at the playhead
  or a layer in an existing clip), setText (typing coalesces into one undo step), set,
  selectLayer, deleteLayer, arrangeLayer, align (frame / selection), distribute,
  graphics.list, fonts.list. EditorState.graphic_layers holds the layer selection.
- goldens: graphic_title, graphic_shapes (CPU and GPU parity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 01:38:04 -04:00

498 lines
19 KiB
Rust

//! The FilmCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`sequence.addEdit`,
//! `timeline.trim`, `markers.markIn`…) 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 (cheap
//! thanks to structural sharing of untouched items).
pub mod autosave;
pub mod captions;
pub mod commands;
pub mod demo;
pub mod graphics;
pub mod interchange;
pub mod media_pool;
pub mod previews;
pub mod trim;
use std::sync::Arc;
use filmcraft_edit::EditCtx;
use filmcraft_project::{ClipId, ItemId, Project, Sequence, TrackId, TrackKind};
use filmcraft_time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use commands::{CommandSpec, command_specs, find as find_command};
pub use filmcraft_export as export;
pub use filmcraft_project as project;
pub use filmcraft_render as render;
pub use filmcraft_time as time;
pub use media_pool::MediaPool;
#[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 sequence")]
NoSequence,
#[error("edit failed: {0}")]
Edit(#[from] filmcraft_edit::EditError),
#[error("media: {0}")]
Media(#[from] filmcraft_media::MediaError),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// Host services (file access, clipboard…) 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 services.
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<()> {
// Atomic + durable: temp file in the same directory, fsync, rename, fsync the directory.
filmcraft_format::atomic_write(std::path::Path::new(path), data)
}
}
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
pub undo: Vec<(String, Arc<Project>)>,
pub redo: Vec<(String, Arc<Project>)>,
/// Labels of all applied states, oldest first (History panel).
pub limit: usize,
}
impl History {
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
}
/// Source patching / track targeting (the track header "V1/A1" source buttons + target toggles).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Targeting {
/// Timeline tracks targeted for navigation / paste / match frame.
pub targeted: Vec<TrackId>,
/// Destination track for the source's video (source patch), None = unpatched.
pub video_dest: Option<TrackId>,
pub audio_dest: Option<TrackId>,
}
/// Editing state that commands depend on (not project data, but headless-relevant).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EditorState {
pub active_sequence: Option<ItemId>,
/// Playhead per sequence.
pub playheads: std::collections::BTreeMap<ItemId, Tick>,
/// Item loaded in the Source monitor and its playhead (media time).
pub source_item: Option<ItemId>,
pub source_playhead: Tick,
/// Selected timeline items.
pub selection: Vec<ClipId>,
/// Selected project panel items.
pub project_selection: Vec<ItemId>,
pub targeting: std::collections::BTreeMap<ItemId, Targeting>,
pub snapping: bool,
pub linked_selection: bool,
/// Open sequences (timeline tabs), in tab order.
pub open_sequences: Vec<ItemId>,
/// Timeline clipboard (serialized track items with their relative track index).
#[serde(skip)]
pub clipboard: Vec<(TrackKind, usize, filmcraft_project::TrackItem)>,
pub default_video_transition: String,
pub default_audio_transition: String,
/// Selected edit points (trim mode).
#[serde(default)]
pub edit_points: Vec<trim::EditPoint>,
/// Selected captions (caption tracks / Captions panel).
#[serde(default)]
pub caption_selection: Vec<ClipId>,
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
#[serde(default)]
pub graphic_layers: Vec<usize>,
}
/// Events for frontends (drained each frame).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub enum Event {
ProjectChanged { revision: u64 },
Toast { message: String, error: bool },
OpenSequence(ItemId),
OpenSource(ItemId),
}
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 media: Arc<MediaPool>,
pub services: Arc<dyn Services>,
pub events: Vec<Event>,
/// Commands executed (for macros/debugging): (id, params).
pub journal: Vec<(String, Value)>,
/// Background jobs (exports, render previews…).
pub jobs: Vec<Job>,
/// User preferences (`prefs.*` commands) and where they persist (None = not persisted).
pub prefs: autosave::Preferences,
pub prefs_path: Option<std::path::PathBuf>,
/// Auto-save ring + crash-recovery journal (native frontends start it; None = off).
pub persistence: Option<autosave::Persistence>,
/// Schema version of the file at `path` as found on disk (older = upgraded on load; the first
/// save over it keeps a backup of the original).
pub loaded_schema: u32,
/// Render preview files + render-bar segments (shared with the frontend's frame workers).
pub previews: Arc<previews::PreviewStore>,
}
/// A background job with shared progress.
#[derive(Clone)]
pub struct Job {
pub id: u64,
pub label: String,
pub progress: Arc<filmcraft_export::Progress>,
pub result: Arc<std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>>,
}
impl Job {
pub fn to_json(&self) -> Value {
use std::sync::atomic::Ordering;
let res = self.result.lock().unwrap_or_else(|e| e.into_inner()).clone();
serde_json::json!({
"id": self.id,
"label": self.label,
"progress": self.progress.fraction(),
"done": self.progress.done.load(Ordering::Relaxed),
"total": self.progress.total.load(Ordering::Relaxed),
"status": self.progress.status.lock().unwrap_or_else(|e| e.into_inner()).clone(),
"finished": res.is_some(),
"result": match res { Some(Ok(r)) => serde_json::to_value(r).unwrap_or_default(), Some(Err(e)) => serde_json::json!({"error": e}), None => Value::Null },
})
}
}
impl Default for Session {
fn default() -> Self {
Self::new(Arc::new(FsServices))
}
}
impl Session {
pub fn new(services: Arc<dyn Services>) -> Self {
Self {
project: Arc::new(Project::new("Untitled")),
history: History { limit: 200, ..Default::default() },
revision: 1,
saved_revision: 1,
path: None,
state: EditorState {
snapping: true,
linked_selection: true,
default_video_transition: "cross_dissolve".into(),
default_audio_transition: "constant_power".into(),
..Default::default()
},
media: Arc::new(MediaPool::default()),
services,
events: Vec::new(),
journal: Vec::new(),
jobs: Vec::new(),
prefs: Default::default(),
prefs_path: None,
persistence: None,
loaded_schema: filmcraft_format::SCHEMA_VERSION,
previews: Arc::new(previews::PreviewStore::temp()),
}
}
/// Start auto-save and the crash-recovery journal (native frontends). Loads preferences from
/// the data directory and finds unsaved changes left by sessions that died
/// ([`Session::recovery_candidates`]).
pub fn start_autosave(&mut self, cfg: autosave::AutosaveConfig) -> std::io::Result<()> {
let prefs_path = cfg.data_dir.join("preferences.json");
self.prefs = autosave::Preferences::load(&prefs_path);
self.prefs_path = Some(prefs_path);
self.persistence = Some(autosave::Persistence::start(&cfg, self.prefs.auto_save.clone())?);
self.sync_persistence();
Ok(())
}
/// Clean shutdown: flush and stop the worker. Unsaved changes stay in the journal (offered on
/// the next launch); otherwise the session's journal directory is removed.
pub fn shutdown(&mut self) {
self.sync_persistence();
if let Some(p) = self.persistence.take() {
p.close();
}
}
pub fn recovery_candidates(&self) -> &[autosave::RecoveryCandidate] {
self.persistence.as_ref().map(|p| p.candidates.as_slice()).unwrap_or(&[])
}
/// Tell the worker about the current project state if it changed (cheap: an `Arc` clone and a
/// channel send; serialization happens on the worker). Called after every command.
pub fn sync_persistence(&mut self) {
let dirty = self.is_dirty();
let Some(p) = self.persistence.as_mut() else { return };
let cur = autosave::Sent { revision: self.revision, saved_revision: self.saved_revision, path: self.path.clone() };
if p.last_sent.as_ref() == Some(&cur) {
return;
}
if dirty {
p.send_dirty(self.project.clone(), self.revision, self.path.clone());
} else {
p.send_clean();
}
p.last_sent = Some(cur);
}
/// Apply worker notifications (call regularly, e.g. once per UI frame).
pub fn poll_persistence(&mut self) {
let Some(p) = self.persistence.as_mut() else { return };
for ev in p.drain_events() {
match ev {
autosave::WorkerEvent::SavedProject { path, revision } => {
if self.path.as_deref() == Some(path.as_str()) && revision > self.saved_revision && revision <= self.revision {
self.saved_revision = revision;
}
}
autosave::WorkerEvent::Error(m) => self.events.push(Event::Toast { message: m, error: true }),
_ => {}
}
}
self.sync_persistence();
}
/// Replace preferences (persisting them and updating the worker).
pub fn set_prefs(&mut self, p: autosave::Preferences) -> std::io::Result<()> {
self.prefs = p;
if let Some(w) = &self.persistence {
w.set_prefs(self.prefs.auto_save.clone());
}
match &self.prefs_path {
Some(path) => self.prefs.save(path),
None => Ok(()),
}
}
/// Keep render previews next to the saved project (moves an unsaved project's previews).
pub fn previews_follow_path(&mut self) {
if let Some(p) = &self.path
&& !cfg!(target_arch = "wasm32")
{
self.previews.move_to(previews::dir_for_project(p));
}
}
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
/// 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()))?;
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let r = (spec.run)(self, &params);
self.sync_persistence();
if r.is_ok() && spec.journal {
self.journal.push((id.to_string(), params));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
}
r
}
pub fn is_enabled(&self, id: &str) -> bool {
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
/// Apply an undoable project edit. The closure gets a mutable copy; on error nothing changes.
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<R>) -> Result<R> {
let mut p = (*self.project).clone();
let mut st = self.state.clone();
let r = f(&mut p, &mut st)?;
let old = std::mem::replace(&mut self.project, Arc::new(p));
self.history.undo.push((label.to_string(), old));
if self.history.undo.len() > self.history.limit {
self.history.undo.remove(0);
}
self.history.redo.clear();
self.state = st;
self.bump();
Ok(r)
}
/// Edit the active sequence with the edit-algebra context.
pub fn edit_sequence<R>(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result<R>) -> Result<R> {
let seq_id = self.state.active_sequence.ok_or(EngineError::NoSequence)?;
let media = self.media.clone();
self.edit(label, move |p, st| {
let project_snapshot = p.clone();
let durations = move |id: ItemId| -> Option<Tick> { media_duration(&project_snapshot, &media, id) };
let min = p.sequence(seq_id).map(|s| s.settings.frame_rate.frame_duration()).unwrap_or(Tick(1));
let mut next = p.next_id;
let r = {
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, min_duration: min };
f(seq, &mut ctx, st)?
};
p.next_id = next;
if let Some(s) = p.sequence(seq_id) {
s.check().map_err(EngineError::Other)?;
}
Ok(r)
})
}
pub fn undo(&mut self) -> Option<String> {
let (label, prev) = self.history.undo.pop()?;
let cur = std::mem::replace(&mut self.project, prev);
self.history.redo.push((label.clone(), cur));
self.fix_state();
self.bump();
Some(label)
}
pub fn redo(&mut self) -> Option<String> {
let (label, next) = self.history.redo.pop()?;
let cur = std::mem::replace(&mut self.project, next);
self.history.undo.push((label.clone(), cur));
self.fix_state();
self.bump();
Some(label)
}
/// Drop dangling references after undo/redo/delete.
pub fn fix_state(&mut self) {
let p = self.project.clone();
if let Some(s) = self.state.active_sequence
&& p.sequence(s).is_none()
{
self.state.active_sequence = p.sequences().next().map(|i| i.id);
}
self.state.open_sequences.retain(|s| p.sequence(*s).is_some());
if let Some(s) = self.state.source_item
&& p.item(s).is_none()
{
self.state.source_item = None;
}
if let Some(seq) = self.state.active_sequence.and_then(|s| p.sequence(s)) {
self.state.selection.retain(|c| seq.find_item(*c).is_some());
self.state.caption_selection.retain(|c| seq.find_caption(*c).is_some());
} else {
self.state.selection.clear();
self.state.caption_selection.clear();
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
}
fn bump(&mut self) {
self.revision += 1;
self.events.push(Event::ProjectChanged { revision: self.revision });
}
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)
}
// ---- accessors ----
pub fn active_sequence(&self) -> Option<&Sequence> {
self.state.active_sequence.and_then(|s| self.project.sequence(s))
}
pub fn playhead(&self) -> Tick {
self.state.active_sequence.and_then(|s| self.state.playheads.get(&s).copied()).unwrap_or_default()
}
pub fn set_playhead(&mut self, t: Tick) {
if let Some(s) = self.state.active_sequence {
let rate = self.active_sequence().map(|s| s.settings.frame_rate).unwrap_or_default();
self.state.playheads.insert(s, rate.snap(t.max(Tick::ZERO)));
}
}
pub fn sequence_rate(&self) -> FrameRate {
self.active_sequence().map(|s| s.settings.frame_rate).unwrap_or_default()
}
pub fn targeting(&self) -> Targeting {
let Some(sid) = self.state.active_sequence else { return Targeting::default() };
if let Some(t) = self.state.targeting.get(&sid) {
return t.clone();
}
// Default: V1/A1 patched and all tracks targeted.
let seq = self.active_sequence();
Targeting {
targeted: seq.map(|s| s.all_tracks().map(|t| t.id).collect()).unwrap_or_default(),
video_dest: seq.and_then(|s| s.video_tracks.first().map(|t| t.id)),
audio_dest: seq.and_then(|s| s.audio_tracks.first().map(|t| t.id)),
}
}
/// The media source for an item (creating it on first use).
pub fn source(&self, item: ItemId) -> Option<filmcraft_media::SharedSource> {
self.media.source_for(&self.project, item, &*self.services)
}
/// Render the active sequence at the playhead (CPU reference path).
pub fn render_program(&self, scale: f32) -> Option<filmcraft_render::Image> {
let seq = self.state.active_sequence?;
let provider = self.media.provider(self.project.clone(), self.services.clone());
let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() };
Some(filmcraft_render::render_sequence(&self.project, seq, self.playhead(), opts, &provider))
}
}
/// Media duration of an item (None for stills/adjustment layers = unlimited handles).
pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick> {
let it = p.item(id)?;
match &it.kind {
filmcraft_project::ItemKind::Media(m) => match m.info.kind {
filmcraft_media::MediaKind::Still | filmcraft_media::MediaKind::Synthetic => None,
_ => Some(m.info.duration),
},
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
filmcraft_project::ItemKind::Subclip { range, .. } => Some(range.end()),
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
}
}
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod file_tests;
#[cfg(test)]
mod previews_tests;
#[cfg(test)]
mod tests;