M11.2: auto-save ring + crash-recovery journal

- Engine autosave module: Preferences (autoSave.enabled/intervalMinutes=5/maxVersions=20/
  saveCurrentProject/recoveryJournal/recoveryIntervalSeconds), persisted in the per-user data
  dir; prefs.get/set/reset commands.
- Background worker (filmcraft-autosave thread): the UI thread only hands over the Arc<Project>
  snapshot; JSON encoding and atomic writes happen on the worker. Premiere-style auto-saves to
  <project dir>/Auto-Save/<name>-YYYY-MM-DD_HH-MM-SS.fcproj (local time), only when changed,
  rotated to Maximum Project Versions, optionally also saving the project.
- Recovery journal: debounced snapshot of unsaved changes in <data>/Recovery/<session>/ guarded
  by an OS file lock; on launch, sessions whose lock is free and that left a snapshot are offered.
  Clean quit removes the journal (or keeps it, marked cleanExit, if changes were unsaved).
- Commands: file.recover, file.discardRecovery, file.recoveryList, file.autoSaveNow,
  file.autoSaveStatus, file.listAutoSaves.
- UI: Preferences ▸ Auto Save page (Edit ▸ Preferences, Cmd+,; FilmCraft ▸ Settings… on macOS),
  "Recover Unsaved Changes" dialog at startup, Revert confirmation; all with automation ids.
- Desktop: --recover / --no-recover / --data-dir; local-time offset via chrono (clock).
- Verified in the app: edits, kill -9, relaunch, dialog, Recover restores the edits (unsaved, original path).
- docs/project-files.md: auto-save, recovery and measured costs (2,000 clips: 2.5 ms encode,
  1.4 MB, ~30 ms atomic write+fsync, all off the UI thread). ROADMAP M11 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 00:10:23 -04:00
parent ba4a04e031
commit 516978b4d4
16 changed files with 1541 additions and 5 deletions

1
Cargo.lock generated
View File

@ -1288,6 +1288,7 @@ dependencies = [
name = "filmcraft"
version = "0.1.0"
dependencies = [
"chrono",
"cpal",
"eframe",
"egui",

View File

@ -39,7 +39,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette; basic scopes | LUT import UI, colour match, colour management + HDR (PQ/HLG, log, tone mapping) | 8–12 |
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), Matroska/WebM import (H.264/HEVC/ProRes/MJPEG + AAC/FLAC/MP3/Vorbis/PCM) | VP9 and Opus (in progress), AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 |
| M10 | Graphics & captions | ⬜ | — | Text engine, Type tool, Essential Graphics, captions (SRT/VTT/SCC), speech-to-text | 20–30 |
| M11 | Interchange & project management | 🟡 | `.fcproj` save/open | Autosave/recovery, FCP7 XML, FCPXML, EDL, OTIO, relink, project manager, proxies | 15 |
| M11 | Interchange & project management | 🟡 | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO | Relink/offline, project manager, proxies | 8 |
| M12–M16 | Web (WASM), platform, long tail | 🟡 | L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), ~850 remaining commands and dialogs, performance hardening | 40–65 |
## Running now

View File

@ -18,6 +18,7 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
cpal = "0.16"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"

View File

@ -1,16 +1,25 @@
//! FilmCraft desktop app.
//!
//! Usage: `filmcraft [--control <port>] [--demo] [project.fcproj | media files…]`
//! Usage: `filmcraft [--control <port>] [--demo|--empty] [--recover|--no-recover] [--data-dir <dir>]
//! [project.fcproj | media files…]`
//!
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `filmcraft_ui_egui::control` for the methods.
//!
//! Auto-save and the crash-recovery journal run in every session (data in `--data-dir`, else
//! `FILMCRAFT_DATA_DIR`, else the per-user application data folder). If a previous session died
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
//! Unsaved Changes…).
mod audio;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use chrono::TimeZone;
use filmcraft_engine::Session;
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
@ -18,12 +27,17 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("FILMCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut recover: Option<bool> = None;
let mut data_dir = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--recover" => recover = Some(true),
"--no-recover" => recover = Some(false),
"--data-dir" => data_dir = args.next().map(std::path::PathBuf::from),
"--version" => {
println!("filmcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
@ -47,6 +61,13 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
}
}
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
@ -59,7 +80,17 @@ fn main() -> eframe::Result {
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => eprintln!("filmcraft: recovered {r}"),
Err(e) => eprintln!("filmcraft: recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
@ -91,3 +122,8 @@ fn main() -> eframe::Result {
}),
)
}
/// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
}

View File

@ -26,6 +26,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
let _ = app_menu.append_items(&[
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id("app.preferences.autoSave", "Settings…", true, None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::separator(),

View File

@ -0,0 +1,684 @@
//! Auto-save ring + crash-recovery journal, and the preferences that drive them.
//!
//! **Auto Save** (Premiere behaviour, Preferences ▸ Auto Save): every *interval* minutes, if the
//! project changed since the last auto-save, a copy is written to the `Auto-Save` folder next to the
//! project as `<name>-YYYY-MM-DD_HH-MM-SS.fcproj`, keeping at most *Maximum Project Versions* files.
//! Optionally the project file itself is saved too.
//!
//! **Recovery journal** (FilmCraft addition): while the project has unsaved changes, a snapshot is
//! written to `<data dir>/Recovery/<session>/snapshot.fcproj` shortly after each change (debounced,
//! and at least every *N* seconds while edits keep coming). Each running app holds an OS lock on
//! `<session>/lock`; when the process dies (crash, `kill -9`, power cut) the lock is released, so on
//! the next launch any session directory whose lock can be taken and that holds a snapshot is a
//! recovery candidate ("Recover unsaved changes from <time>?").
//!
//! **Threading.** The UI thread only hands the worker an `Arc<Project>` (the immutable snapshot the
//! engine already keeps for undo) — a pointer copy and a channel send. Serialization and all file
//! I/O happen on the worker thread, so neither ever stalls the UI.
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use filmcraft_format::autosave as names;
use filmcraft_project::Project;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
// ------------------------------------------------------------------ preferences
/// Preferences ▸ Auto Save.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AutoSavePrefs {
/// "Automatically save projects".
pub enabled: bool,
/// "Automatically Save Every: N minute(s)".
pub interval_minutes: u32,
/// "Maximum Project Versions".
pub max_versions: u32,
/// "Auto Save also saves the current project(s)".
pub save_current_project: bool,
/// Keep a crash-recovery journal of unsaved changes.
pub recovery_journal: bool,
/// Longest time (s) unsaved changes may stay out of the journal while edits keep coming.
pub recovery_interval_seconds: u32,
}
impl Default for AutoSavePrefs {
fn default() -> Self {
Self { enabled: true, interval_minutes: 5, max_versions: 20, save_current_project: false, recovery_journal: true, recovery_interval_seconds: 5 }
}
}
impl AutoSavePrefs {
fn clamp(&mut self) {
self.interval_minutes = self.interval_minutes.clamp(1, 1440);
self.max_versions = self.max_versions.clamp(1, 1000);
self.recovery_interval_seconds = self.recovery_interval_seconds.clamp(1, 600);
}
}
/// User preferences (persisted as JSON in the per-user data directory). Keys are dotted camelCase
/// paths: `autoSave.enabled`, `autoSave.intervalMinutes`, …
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Preferences {
pub auto_save: AutoSavePrefs,
}
impl Preferences {
pub fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or_default()
}
/// All leaf keys (`autoSave.enabled`, …).
pub fn keys(&self) -> Vec<String> {
fn walk(prefix: &str, v: &Value, out: &mut Vec<String>) {
match v {
Value::Object(m) => {
for (k, c) in m {
walk(&if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }, c, out);
}
}
_ => out.push(prefix.to_string()),
}
}
let mut out = Vec::new();
walk("", &self.to_value(), &mut out);
out
}
pub fn get(&self, key: &str) -> Option<Value> {
let v = self.to_value();
key.split('.').try_fold(&v, |v, k| v.get(k)).cloned()
}
/// Set one key (type-checked against the schema, values clamped to their ranges).
pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> {
let mut v = self.to_value();
let mut slot = &mut v;
for k in key.split('.') {
slot = slot.get_mut(k).ok_or_else(|| format!("unknown preference `{key}`"))?;
}
if slot.is_object() {
return Err(format!("`{key}` is a group; set one of its keys"));
}
let value = match (&*slot, value) {
// Accept 5.0 for integer prefs (agents often send floats).
(Value::Number(n), Value::Number(m)) if n.is_u64() && !m.is_u64() => {
Value::from(m.as_f64().filter(|f| f.is_finite() && *f >= 0.0).ok_or_else(|| format!("`{key}` must be a non-negative number"))?.round() as u64)
}
(_, v) => v,
};
*slot = value;
let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
p.auto_save.clamp();
*self = p;
Ok(())
}
pub fn load(path: &Path) -> Self {
let mut p: Preferences = fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
p.auto_save.clamp();
p
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(d) = path.parent() {
fs::create_dir_all(d)?;
}
filmcraft_format::atomic_write(path, &serde_json::to_vec_pretty(self).unwrap_or_default())
}
}
/// The per-user data directory (`FILMCRAFT_DATA_DIR` overrides):
/// macOS `~/Library/Application Support/FilmCraft`, Windows `%APPDATA%\FilmCraft`,
/// elsewhere `$XDG_DATA_HOME/filmcraft` or `~/.local/share/filmcraft`.
pub fn default_data_dir() -> Option<PathBuf> {
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
if let Some(d) = env("FILMCRAFT_DATA_DIR") {
return Some(d);
}
if cfg!(target_os = "macos") {
return env("HOME").map(|h| h.join("Library/Application Support/FilmCraft"));
}
if cfg!(windows) {
return env("APPDATA").map(|h| h.join("FilmCraft"));
}
env("XDG_DATA_HOME").map(|d| d.join("filmcraft")).or_else(|| env("HOME").map(|h| h.join(".local/share/filmcraft")))
}
pub fn unix_now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
// ------------------------------------------------------------------ recovery candidates
/// Snapshot meta written next to `snapshot.fcproj`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct JournalMeta {
pub pid: u32,
pub started_unix: i64,
pub saved_unix: i64,
pub revision: u64,
pub project_name: String,
pub project_path: Option<String>,
pub generator: String,
pub bytes: usize,
/// True when the app was closed normally while changes were unsaved (no crash).
pub clean_exit: bool,
}
/// Unsaved changes left behind by a session that is no longer running.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoveryCandidate {
/// Session directory name (pass to `file.recover {id}`).
pub id: String,
#[serde(skip)]
pub dir: PathBuf,
#[serde(flatten)]
pub meta: JournalMeta,
}
const SNAPSHOT: &str = "snapshot.fcproj";
const META: &str = "session.json";
const LOCK: &str = "lock";
pub fn recovery_root(data_dir: &Path) -> PathBuf {
data_dir.join("Recovery")
}
/// Is the session directory's lock free (owner gone)?
fn lock_is_stale(dir: &Path) -> bool {
let Ok(f) = File::options().read(true).write(true).open(dir.join(LOCK)) else { return true };
match f.try_lock() {
Ok(()) => true,
Err(fs::TryLockError::WouldBlock) => false,
// Locking unsupported on this file system: fall back to the meta's pid being gone is not
// knowable portably, so treat as stale (the snapshot is offered, never deleted silently).
Err(fs::TryLockError::Error(_)) => true,
}
}
/// Find snapshots of sessions that are no longer running. Stale session directories without a
/// snapshot (nothing unsaved) are removed. `skip` is the caller's own session directory.
pub fn scan_candidates(data_dir: &Path, skip: Option<&Path>) -> Vec<RecoveryCandidate> {
let Ok(rd) = fs::read_dir(recovery_root(data_dir)) else { return Vec::new() };
let mut out = Vec::new();
for e in rd.filter_map(|e| e.ok()) {
let dir = e.path();
if !dir.is_dir() || Some(dir.as_path()) == skip || !lock_is_stale(&dir) {
continue;
}
let meta = fs::read(dir.join(META)).ok().and_then(|b| serde_json::from_slice::<JournalMeta>(&b).ok());
match meta {
Some(meta) if dir.join(SNAPSHOT).is_file() => {
out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta });
}
_ => {
let _ = fs::remove_dir_all(&dir);
}
}
}
out.sort_by(|a, b| b.meta.saved_unix.cmp(&a.meta.saved_unix).then(b.id.cmp(&a.id)));
out
}
/// Load a candidate's snapshot.
pub fn load_candidate(c: &RecoveryCandidate) -> Result<filmcraft_format::Loaded, String> {
let b = fs::read(c.dir.join(SNAPSHOT)).map_err(|e| format!("{}: {e}", c.dir.display()))?;
filmcraft_format::decode(&b).map_err(|e| e.to_string())
}
pub fn discard_candidate(c: &RecoveryCandidate) -> io::Result<()> {
fs::remove_dir_all(&c.dir)
}
// ------------------------------------------------------------------ worker
/// Host configuration for [`Persistence::start`].
#[derive(Clone)]
pub struct AutosaveConfig {
pub data_dir: PathBuf,
/// Local UTC offset (seconds) at a unix time — used for auto-save file names and display.
pub local_offset: fn(i64) -> i32,
/// Quiet period after a change before the journal is written.
pub journal_debounce: Duration,
}
impl AutosaveConfig {
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self { data_dir: data_dir.into(), local_offset: |_| 0, journal_debounce: Duration::from_millis(1000) }
}
}
/// What the session last told the worker.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Sent {
pub revision: u64,
pub saved_revision: u64,
pub path: Option<String>,
}
struct Snapshot {
project: Arc<Project>,
revision: u64,
path: Option<String>,
}
enum Msg {
/// The project has unsaved changes at this revision.
Dirty(Snapshot),
/// Nothing unsaved (just saved / opened): drop the journal.
Clean,
Prefs(AutoSavePrefs),
AutoSaveNow(Sender<Result<PathBuf, String>>),
Flush(Sender<()>),
Stop,
}
/// Worker → session notifications.
#[derive(Clone, Debug)]
pub enum WorkerEvent {
Journaled {
revision: u64,
unix: i64,
serialize_ms: f64,
write_ms: f64,
bytes: usize,
},
AutoSaved {
path: PathBuf,
revision: u64,
unix: i64,
serialize_ms: f64,
write_ms: f64,
bytes: usize,
},
/// "Auto Save also saves the current project" wrote the project file at this revision.
SavedProject {
path: String,
revision: u64,
},
Error(String),
}
/// Last activity, for `file.autoSaveStatus` and the UI.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Status {
pub last_auto_save: Option<String>,
pub last_auto_save_unix: Option<i64>,
pub auto_saves_written: u64,
pub last_journal_unix: Option<i64>,
pub journaled_revision: u64,
pub journals_written: u64,
/// Worker-side cost of the last write (serialize = JSON encode of the snapshot).
pub last_serialize_ms: f64,
pub last_write_ms: f64,
pub last_bytes: usize,
pub last_error: Option<String>,
}
/// The running auto-save/recovery machinery of one session.
pub struct Persistence {
pub data_dir: PathBuf,
pub session_dir: PathBuf,
pub local_offset: fn(i64) -> i32,
pub candidates: Vec<RecoveryCandidate>,
pub status: Status,
pub(crate) last_sent: Option<Sent>,
lock: Option<File>,
tx: Sender<Msg>,
events: Receiver<WorkerEvent>,
thread: Option<JoinHandle<()>>,
}
impl Persistence {
/// Create this session's journal directory (holding its lock), find recovery candidates left by
/// dead sessions, and start the worker thread.
pub fn start(cfg: &AutosaveConfig, prefs: AutoSavePrefs) -> io::Result<Self> {
let root = recovery_root(&cfg.data_dir);
fs::create_dir_all(&root)?;
let started = unix_now();
let pid = std::process::id();
// Unique even for several sessions in one process within a second (tests, future multi-window).
static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let session_dir = root.join(if seq == 0 { format!("{started}-{pid}") } else { format!("{started}-{pid}-{seq}") });
fs::create_dir_all(&session_dir)?;
let lock = File::options().create(true).truncate(false).read(true).write(true).open(session_dir.join(LOCK))?;
// Unsupported locking only weakens stale detection; it must not stop the app.
let _ = lock.try_lock();
let candidates = scan_candidates(&cfg.data_dir, Some(&session_dir));
let (tx, rx) = channel();
let (etx, events) = channel();
let mut w = Worker {
data_dir: cfg.data_dir.clone(),
session_dir: session_dir.clone(),
local_offset: cfg.local_offset,
debounce: cfg.journal_debounce,
prefs,
events: etx,
latest: None,
journaled: 0,
autosaved: 0,
first_pending: None,
last_change: Instant::now(),
next_autosave: Instant::now(),
meta: JournalMeta { pid, started_unix: started, generator: filmcraft_format::generator(), ..Default::default() },
};
w.next_autosave = Instant::now() + w.autosave_interval();
let thread = std::thread::Builder::new().name("filmcraft-autosave".into()).spawn(move || w.run(rx))?;
Ok(Self {
data_dir: cfg.data_dir.clone(),
session_dir,
local_offset: cfg.local_offset,
candidates,
status: Status::default(),
last_sent: None,
lock: Some(lock),
tx,
events,
thread: Some(thread),
})
}
pub(crate) fn send_dirty(&self, project: Arc<Project>, revision: u64, path: Option<String>) {
let _ = self.tx.send(Msg::Dirty(Snapshot { project, revision, path }));
}
pub(crate) fn send_clean(&self) {
let _ = self.tx.send(Msg::Clean);
}
pub fn set_prefs(&self, p: AutoSavePrefs) {
let _ = self.tx.send(Msg::Prefs(p));
}
/// Write any pending journal now and wait for it.
pub fn flush(&self) {
let (tx, rx) = channel();
if self.tx.send(Msg::Flush(tx)).is_ok() {
let _ = rx.recv_timeout(Duration::from_secs(30));
}
}
/// Write an auto-save now (if there are unsaved changes) and wait for the result.
pub fn auto_save_now(&self) -> Result<Option<PathBuf>, String> {
let (tx, rx) = channel();
self.tx.send(Msg::AutoSaveNow(tx)).map_err(|_| "auto-save worker stopped".to_string())?;
match rx.recv_timeout(Duration::from_secs(60)) {
Ok(Ok(p)) => Ok(Some(p)),
Ok(Err(e)) if e.is_empty() => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err("auto-save timed out".into()),
}
}
pub fn drain_events(&mut self) -> Vec<WorkerEvent> {
let ev: Vec<_> = self.events.try_iter().collect();
for e in &ev {
match e {
WorkerEvent::Journaled { revision, unix, serialize_ms, write_ms, bytes } => {
self.status.last_journal_unix = Some(*unix);
self.status.journaled_revision = *revision;
self.status.journals_written += 1;
(self.status.last_serialize_ms, self.status.last_write_ms, self.status.last_bytes) = (*serialize_ms, *write_ms, *bytes);
}
WorkerEvent::AutoSaved { path, unix, serialize_ms, write_ms, bytes, .. } => {
self.status.last_auto_save = Some(path.to_string_lossy().into_owned());
self.status.last_auto_save_unix = Some(*unix);
self.status.auto_saves_written += 1;
(self.status.last_serialize_ms, self.status.last_write_ms, self.status.last_bytes) = (*serialize_ms, *write_ms, *bytes);
}
WorkerEvent::SavedProject { .. } => {}
WorkerEvent::Error(m) => self.status.last_error = Some(m.clone()),
}
}
ev
}
/// Stop the worker. A session that still has a journal (closed with unsaved changes) keeps its
/// directory — marked as a clean exit — so the changes are offered next time; otherwise the
/// directory is removed.
pub fn close(mut self) {
self.flush();
self.stop_worker();
let snap = self.session_dir.join(SNAPSHOT);
self.lock.take();
if snap.is_file() {
if let Some(mut m) = fs::read(self.session_dir.join(META)).ok().and_then(|b| serde_json::from_slice::<JournalMeta>(&b).ok()) {
m.clean_exit = true;
let _ = filmcraft_format::atomic_write(&self.session_dir.join(META), &serde_json::to_vec_pretty(&m).unwrap_or_default());
}
} else {
let _ = fs::remove_dir_all(&self.session_dir);
}
}
/// Stop the worker and release the lock *without* cleaning up — what a crash leaves behind.
#[doc(hidden)]
pub fn simulate_crash(mut self) {
self.flush();
self.stop_worker();
self.lock.take();
}
fn stop_worker(&mut self) {
let _ = self.tx.send(Msg::Stop);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
/// Display form of a unix time in local time.
pub fn local_time(&self, unix: i64) -> String {
names::display_time(unix, (self.local_offset)(unix))
}
pub fn candidates_json(&self) -> Value {
Value::Array(
self.candidates
.iter()
.map(|c| {
let mut v = serde_json::to_value(c).unwrap_or_default();
v["savedAt"] = json!(self.local_time(c.meta.saved_unix));
v
})
.collect(),
)
}
}
impl Drop for Persistence {
fn drop(&mut self) {
self.stop_worker();
}
}
struct Worker {
data_dir: PathBuf,
session_dir: PathBuf,
local_offset: fn(i64) -> i32,
debounce: Duration,
prefs: AutoSavePrefs,
events: Sender<WorkerEvent>,
/// Newest unsaved state (None = clean).
latest: Option<Snapshot>,
/// Revision currently in the journal (0 = none) / last auto-saved revision.
journaled: u64,
autosaved: u64,
first_pending: Option<Instant>,
last_change: Instant,
next_autosave: Instant,
meta: JournalMeta,
}
impl Worker {
fn autosave_interval(&self) -> Duration {
Duration::from_secs(self.prefs.interval_minutes as u64 * 60)
}
fn journal_due(&self) -> Option<Instant> {
let s = self.latest.as_ref()?;
if !self.prefs.recovery_journal || s.revision == self.journaled {
return None;
}
let max = self.first_pending.unwrap_or(self.last_change) + Duration::from_secs(self.prefs.recovery_interval_seconds as u64);
Some((self.last_change + self.debounce).min(max))
}
fn run(mut self, rx: Receiver<Msg>) {
loop {
let due = [self.journal_due(), self.prefs.enabled.then_some(self.next_autosave)].into_iter().flatten().min();
let msg = match due {
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
Ok(m) => Some(m),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => return,
},
None => match rx.recv() {
Ok(m) => Some(m),
Err(_) => return,
},
};
match msg {
Some(Msg::Dirty(s)) => {
let now = Instant::now();
self.first_pending.get_or_insert(now);
self.last_change = now;
self.latest = Some(s);
}
Some(Msg::Clean) => {
self.latest = None;
self.first_pending = None;
self.clear_journal();
}
Some(Msg::Prefs(p)) => {
let was = self.autosave_interval();
let journal_was = self.prefs.recovery_journal;
self.prefs = p;
if self.autosave_interval() != was {
self.next_autosave = Instant::now() + self.autosave_interval();
}
if journal_was && !self.prefs.recovery_journal {
self.clear_journal();
}
}
Some(Msg::AutoSaveNow(reply)) => {
let r = if self.latest.is_some() { self.auto_save() } else { Err(String::new()) };
let _ = reply.send(r);
}
Some(Msg::Flush(ack)) => {
if self.journal_due().is_some() {
self.write_journal();
}
let _ = ack.send(());
}
Some(Msg::Stop) => return,
None => {}
}
let now = Instant::now();
if self.journal_due().is_some_and(|d| d <= now) {
self.write_journal();
}
if self.prefs.enabled && self.next_autosave <= now {
if self.latest.as_ref().is_some_and(|s| s.revision != self.autosaved) {
let _ = self.auto_save();
}
self.next_autosave = now + self.autosave_interval();
}
}
}
fn error(&self, e: String) {
log::warn!("auto-save: {e}");
let _ = self.events.send(WorkerEvent::Error(e));
}
fn write_journal(&mut self) {
let Some(s) = &self.latest else { return };
let t0 = Instant::now();
let bytes = filmcraft_format::encode(&s.project, false);
let serialize_ms = t0.elapsed().as_secs_f64() * 1000.0;
let t1 = Instant::now();
let unix = unix_now();
let mut meta = self.meta.clone();
meta.saved_unix = unix;
meta.revision = s.revision;
meta.project_name = s.project.name.clone();
meta.project_path = s.path.clone();
meta.bytes = bytes.len();
// Snapshot first, then the meta that makes it a candidate.
let r = filmcraft_format::atomic_write(&self.session_dir.join(SNAPSHOT), &bytes)
.and_then(|_| filmcraft_format::atomic_write(&self.session_dir.join(META), &serde_json::to_vec_pretty(&meta).unwrap_or_default()));
let write_ms = t1.elapsed().as_secs_f64() * 1000.0;
match r {
Ok(()) => {
self.journaled = s.revision;
self.first_pending = None;
let _ = self.events.send(WorkerEvent::Journaled { revision: s.revision, unix, serialize_ms, write_ms, bytes: bytes.len() });
}
Err(e) => {
// Try again at the next interval rather than spinning.
self.first_pending = Some(Instant::now());
self.last_change = Instant::now();
self.error(format!("recovery journal: {e}"));
}
}
}
fn clear_journal(&mut self) {
let _ = fs::remove_file(self.session_dir.join(META));
let _ = fs::remove_file(self.session_dir.join(SNAPSHOT));
self.journaled = 0;
}
fn auto_save(&mut self) -> Result<PathBuf, String> {
let Some(s) = &self.latest else { return Err("nothing to save".into()) };
let (dir, name) = match &s.path {
Some(p) => {
let p = Path::new(p);
(names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))
}
None => (self.data_dir.join(names::AUTO_SAVE_DIR), s.project.name.clone()),
};
let t0 = Instant::now();
let bytes = filmcraft_format::encode(&s.project, false);
let serialize_ms = t0.elapsed().as_secs_f64() * 1000.0;
let t1 = Instant::now();
let unix = unix_now();
let r = names::write_auto_save(&dir, &name, unix, (self.local_offset)(unix), &bytes, self.prefs.max_versions as usize);
let write_ms = t1.elapsed().as_secs_f64() * 1000.0;
match r {
Ok(path) => {
self.autosaved = s.revision;
let _ = self.events.send(WorkerEvent::AutoSaved { path: path.clone(), revision: s.revision, unix, serialize_ms, write_ms, bytes: bytes.len() });
if self.prefs.save_current_project
&& let Some(p) = &s.path
{
match filmcraft_format::atomic_write(Path::new(p), &bytes) {
Ok(()) => {
let _ = self.events.send(WorkerEvent::SavedProject { path: p.clone(), revision: s.revision });
}
Err(e) => self.error(format!("saving {p}: {e}")),
}
}
Ok(path)
}
Err(e) => {
let m = format!("auto-save to {}: {e}", dir.display());
self.error(m.clone());
Err(m)
}
}
}
}

View File

@ -0,0 +1,196 @@
//! Preferences, auto-save ring and crash recovery through the command interface.
use super::*;
use serde_json::json;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("fc-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn wait_for(what: &str, mut f: impl FnMut() -> bool) {
let t0 = std::time::Instant::now();
while !f() {
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "timed out waiting for {what}");
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn start(s: &mut Session, data: &std::path::Path) {
let mut cfg = autosave::AutosaveConfig::new(data);
cfg.journal_debounce = std::time::Duration::from_millis(20);
s.start_autosave(cfg).unwrap();
}
fn journaled(s: &mut Session) -> u64 {
s.poll_persistence();
s.persistence.as_ref().unwrap().status.journaled_revision
}
#[test]
fn preferences_get_set_and_persist() {
let d = temp_dir("prefs");
let mut s = Session::default();
start(&mut s, &d);
assert_eq!(s.execute("prefs.get", json!({"key": "autoSave.intervalMinutes"})).unwrap(), json!(5));
assert_eq!(s.execute("prefs.get", json!({"key": "autoSave.maxVersions"})).unwrap(), json!(20));
s.execute("prefs.set", json!({"key": "autoSave.intervalMinutes", "value": 12.0})).unwrap();
s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 5000, "autoSave.saveCurrentProject": true}})).unwrap();
assert_eq!(s.prefs.auto_save.interval_minutes, 12);
assert_eq!(s.prefs.auto_save.max_versions, 1000, "clamped");
assert!(s.execute("prefs.set", json!({"key": "autoSave.nope", "value": 1})).is_err());
assert!(s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": "yes"})).is_err());
let keys = s.execute("prefs.get", json!({})).unwrap()["keys"].clone();
assert!(keys.as_array().unwrap().iter().any(|k| k == "autoSave.recoveryJournal"));
s.shutdown();
let mut t = Session::default();
start(&mut t, &d);
assert_eq!(t.prefs.auto_save.interval_minutes, 12);
assert!(t.prefs.auto_save.save_current_project);
t.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn crash_recovery_end_to_end() {
let d = temp_dir("recover");
let proj = d.join("film.fcproj").to_string_lossy().to_string();
// Session A: saved project, then unsaved edits, then a crash.
let mut a = demo();
start(&mut a, &d);
a.execute("file.saveAs", json!({"path": proj})).unwrap();
a.execute("sequence.addEdit", json!({"seconds": 1.5})).unwrap();
a.execute("sequence.addEdit", json!({"seconds": 2.5})).unwrap();
let rev = a.revision;
wait_for("journal", || journaled(&mut a) == rev);
let st = a.execute("file.autoSaveStatus", json!({})).unwrap();
assert!(st["status"]["lastBytes"].as_u64().unwrap() > 1000, "{st}");
let edited = (*a.project).clone();
// Session B starts while A is alive: A's journal is not stale.
let mut b = Session::default();
start(&mut b, &d);
assert!(b.recovery_candidates().is_empty(), "a live session's journal is never offered");
b.shutdown();
a.persistence.take().unwrap().simulate_crash();
// Session C: next launch.
let mut c = Session::default();
start(&mut c, &d);
assert_eq!(c.recovery_candidates().len(), 1);
let list = c.execute("file.recoveryList", json!({})).unwrap();
assert_eq!(list[0]["projectName"], "FilmCraft Demo");
assert_eq!(list[0]["projectPath"], proj.as_str());
assert_eq!(list[0]["cleanExit"], false);
assert_eq!(list[0]["savedAt"].as_str().unwrap().len(), 19);
c.execute("file.recover", json!({})).unwrap();
assert_eq!(*c.project, edited);
assert!(c.is_dirty(), "recovered changes are unsaved");
assert_eq!(c.path.as_deref(), Some(proj.as_str()));
assert!(c.recovery_candidates().is_empty());
assert!(!c.is_enabled("file.recover"));
// The recovered state is already in C's own journal (a crash now loses nothing).
assert_eq!(journaled(&mut c), c.revision);
c.execute("file.save", json!({})).unwrap();
wait_for("journal cleared", || {
c.poll_persistence();
!c.persistence.as_ref().unwrap().session_dir.join("snapshot.fcproj").exists()
});
let session_dir = c.persistence.as_ref().unwrap().session_dir.clone();
c.shutdown();
assert!(!session_dir.exists(), "clean exit with nothing unsaved removes the journal");
let mut e = Session::default();
start(&mut e, &d);
assert!(e.recovery_candidates().is_empty());
e.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn quitting_with_unsaved_changes_keeps_them_for_next_launch() {
let d = temp_dir("quitdirty");
let mut a = demo();
start(&mut a, &d);
a.execute("sequence.addEdit", json!({"seconds": 1.0})).unwrap();
a.shutdown();
let mut b = Session::default();
start(&mut b, &d);
let list = b.execute("file.recoveryList", json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 1);
assert_eq!(list[0]["cleanExit"], true);
b.execute("file.discardRecovery", json!({})).unwrap();
assert!(b.recovery_candidates().is_empty());
b.shutdown();
let mut c = Session::default();
start(&mut c, &d);
assert!(c.recovery_candidates().is_empty(), "discarded for good");
c.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn auto_save_ring_rotates_and_can_save_the_project() {
let d = temp_dir("ring");
let proj = d.join("ring.fcproj").to_string_lossy().to_string();
let mut s = demo();
start(&mut s, &d);
s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 2}})).unwrap();
s.execute("file.saveAs", json!({"path": proj})).unwrap();
// Nothing unsaved: no auto-save.
assert!(s.execute("file.autoSaveNow", json!({})).unwrap()["path"].is_null());
let mut written = Vec::new();
for i in 0..3 {
s.execute("sequence.addEdit", json!({"seconds": 1.0 + i as f64})).unwrap();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
written.push(r["path"].as_str().unwrap().to_string());
// File names have one-second resolution.
std::thread::sleep(std::time::Duration::from_millis(1100));
}
let sep = std::path::MAIN_SEPARATOR;
assert!(written[0].contains(&format!("{sep}Auto-Save{sep}ring-")), "{}", written[0]);
let list = s.execute("file.listAutoSaves", json!({})).unwrap();
let files: Vec<String> = list["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files, vec![written[2].clone(), written[1].clone()], "newest first, oldest pruned");
let mut t = Session::default();
t.execute("file.open", json!({"path": written[2]})).unwrap();
assert_eq!(*t.project, *s.project);
assert!(s.is_dirty(), "auto-save does not save the project itself by default");
s.execute("prefs.set", json!({"key": "autoSave.saveCurrentProject", "value": true})).unwrap();
s.execute("sequence.addEdit", json!({"seconds": 5.0})).unwrap();
s.execute("file.autoSaveNow", json!({})).unwrap();
wait_for("project saved", || {
s.poll_persistence();
!s.is_dirty()
});
let mut t = Session::default();
t.execute("file.open", json!({"path": proj})).unwrap();
assert_eq!(*t.project, *s.project);
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn journal_costs_the_ui_thread_only_a_handoff() {
// Per change the UI thread pays an Arc clone and a channel send; encoding happens on the worker.
let d = temp_dir("cost");
let mut s = demo();
start(&mut s, &d);
let t0 = std::time::Instant::now();
for i in 0..50 {
s.execute("playhead.set", json!({"seconds": i as f64 * 0.1})).unwrap();
let _ = s.execute("sequence.addEdit", json!({}));
}
let per_cmd = t0.elapsed() / 100;
let rev = s.revision;
wait_for("journal", || journaled(&mut s) == rev);
assert!(per_cmd < std::time::Duration::from_millis(50), "{per_cmd:?}");
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}

View File

@ -68,6 +68,10 @@ fn has_edit_points(s: &Session) -> std::result::Result<(), String> {
fn has_saved_path(s: &Session) -> std::result::Result<(), String> {
if s.path.is_some() { Ok(()) } else { Err("the project has not been saved yet".into()) }
}
fn has_recovery(s: &Session) -> std::result::Result<(), String> {
if s.recovery_candidates().is_empty() { Err("there are no unsaved changes to recover".into()) } else { Ok(()) }
}
fn has_seq(s: &Session) -> std::result::Result<(), String> {
s.active_sequence().map(|_| ()).ok_or_else(|| "no sequence is open".into())
}
@ -574,6 +578,77 @@ fn build() -> Vec<CommandSpec> {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "need `path`"))?.to_string();
open_project(s, &path)
}),
cmd!("file.recover", "Recover Unsaved Changes…", ["File"], None, r#"{"id":str?}"#, has_recovery, |s, p| recover(s, str_p(p, "id"))),
cmd!("file.discardRecovery", "Discard Unsaved Changes", [], None, r#"{"id":str?,"all":bool?}"#, has_recovery, |s, p| {
let all = bool_p(p, "all").unwrap_or(false);
let id = str_p(p, "id").map(str::to_string);
let Some(per) = s.persistence.as_mut() else { return Ok(json!({"discarded": 0})) };
let mut n = 0;
let mut keep = Vec::new();
for (i, c) in std::mem::take(&mut per.candidates).into_iter().enumerate() {
let hit = all || id.as_deref().map_or(i == 0, |want| want == c.id);
if hit {
crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?;
n += 1;
} else {
keep.push(c);
}
}
per.candidates = keep;
if n == 0 {
return Err(bad("file.discardRecovery", "no such recovery session"));
}
Ok(json!({"discarded": n}))
}),
query!("file.recoveryList", "List Recoverable Sessions", "{}", |s, _| {
Ok(s.persistence.as_ref().map(|p| p.candidates_json()).unwrap_or_else(|| json!([])))
}),
cmd!("file.autoSaveNow", "Auto Save Now", [], None, "{}", always, |s, _| {
let per = s.persistence.as_ref().ok_or_else(|| EngineError::Other("auto-save is not running in this session".into()))?;
let r = per.auto_save_now().map_err(EngineError::Other)?;
s.poll_persistence();
Ok(json!({"path": r.map(|p| p.to_string_lossy().into_owned())}))
}),
query!("file.autoSaveStatus", "Auto Save Status", "{}", |s, _| {
let Some(per) = s.persistence.as_ref() else { return Ok(json!({"running": false, "prefs": s.prefs.auto_save})) };
Ok(json!({
"running": true,
"prefs": s.prefs.auto_save,
"dataDir": per.data_dir,
"sessionDir": per.session_dir,
"autoSaveDir": auto_save_dir(s),
"status": per.status,
"lastAutoSaveAt": per.status.last_auto_save_unix.map(|u| per.local_time(u)),
"lastJournalAt": per.status.last_journal_unix.map(|u| per.local_time(u)),
"recoverable": per.candidates.len(),
}))
}),
query!("file.listAutoSaves", "Browse Auto-Saves", "{}", |s, _| {
let dir = auto_save_dir(s);
let files = filmcraft_format::autosave::list_auto_saves(&dir, &auto_save_name(s));
Ok(json!({"dir": dir, "files": files.iter().rev().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>()}))
}),
query!("prefs.get", "Get Preferences", r#"{"key":str?}"#, |s, p| match str_p(p, "key") {
Some(k) => s.prefs.get(k).ok_or_else(|| bad("prefs.get", format!("unknown preference `{k}`"))),
None => Ok(json!({"values": s.prefs.to_value(), "keys": s.prefs.keys()})),
}),
cmd!("prefs.set", "Set Preferences", [], None, r#"{"key":str,"value":any}|{"values":{key:value}}"#, always, |s, p| {
let mut next = s.prefs.clone();
if let Some(k) = str_p(p, "key") {
next.set(k, p.get("value").cloned().ok_or_else(|| bad("prefs.set", "need `value`"))?).map_err(|e| bad("prefs.set", e))?;
}
if let Some(m) = p.get("values").and_then(Value::as_object) {
for (k, v) in m {
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
}
}
s.set_prefs(next).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
Ok(s.prefs.to_value())
}),
cmd!("prefs.reset", "Reset Preferences", [], None, "{}", always, |s, _| {
s.set_prefs(Default::default()).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
Ok(s.prefs.to_value())
}),
cmd!(
"file.exportMedia",
"Media…",
@ -2170,3 +2245,38 @@ fn open_project(s: &mut Session, path: &str) -> Result<Value> {
}
Ok(json!({"path": path, "schemaVersion": from, "migrated": migrated}))
}
/// Load a recovery candidate (newest when `id` is None) as the current, unsaved project.
fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
let per = s.persistence.as_ref().ok_or_else(|| EngineError::Other("recovery is not running".into()))?;
let idx = match id {
Some(id) => per.candidates.iter().position(|c| c.id == id).ok_or_else(|| bad("file.recover", format!("no recovery session `{id}`")))?,
None => 0,
};
let c = per.candidates[idx].clone();
let saved_at = per.local_time(c.meta.saved_unix);
let loaded = crate::autosave::load_candidate(&c).map_err(EngineError::Other)?;
install_project(s, loaded.project, c.meta.project_path.clone(), false);
// Our own journal must hold the recovered state before the old one is deleted.
s.sync_persistence();
if let Some(per) = s.persistence.as_mut() {
per.flush();
per.candidates.remove(idx);
}
crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?;
s.toast(format!("Recovered unsaved changes to {} from {saved_at}", c.meta.project_name));
Ok(json!({"id": c.id, "name": c.meta.project_name, "path": c.meta.project_path, "savedAt": saved_at, "revision": c.meta.revision}))
}
/// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf {
match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)),
(None, Some(per)) => per.data_dir.join(filmcraft_format::autosave::AUTO_SAVE_DIR),
(None, None) => std::path::PathBuf::from(filmcraft_format::autosave::AUTO_SAVE_DIR),
}
}
fn auto_save_name(s: &Session) -> String {
s.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone())
}

View File

@ -8,6 +8,7 @@
//! 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 commands;
pub mod demo;
pub mod interchange;
@ -146,6 +147,11 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Background jobs (exports, …).
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,
@ -203,10 +209,84 @@ impl Session {
events: Vec::new(),
journal: Vec::new(),
jobs: Vec::new(),
prefs: Default::default(),
prefs_path: None,
persistence: None,
loaded_schema: filmcraft_format::SCHEMA_VERSION,
}
}
/// 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(()),
}
}
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
@ -216,6 +296,7 @@ impl Session {
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 {
@ -382,6 +463,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
}
}
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod file_tests;
#[cfg(test)]

View File

@ -55,6 +55,12 @@ pub enum Dialog {
About,
Shortcuts,
NewSequence,
/// Preferences ▸ Auto Save.
Preferences,
/// "Recover unsaved changes from <time>?" (shown at startup when a dead session left some).
Recovery,
/// "Are you sure you want to discard your changes?" (File ▸ Revert).
RevertConfirm,
}
#[derive(Default)]
@ -81,6 +87,7 @@ pub struct FilmcraftApp {
pub audio: Option<Box<dyn AudioOut>>,
pub hooks: HostHooks,
pub dialog: Option<Dialog>,
pub file_dialogs: panels::file_dialogs::FileDialogState,
pub auto: automation::Registry,
/// Named textures (monitors, thumbnails) with the key they show.
textures: HashMap<String, (FrameKey, TextureHandle)>,
@ -158,6 +165,7 @@ impl FilmcraftApp {
impl FilmcraftApp {
pub fn new(session: Session) -> Self {
let recovery = !session.recovery_candidates().is_empty();
let frames = Arc::new(FrameServer::new(
session.media.clone(),
session.services.clone(),
@ -171,7 +179,9 @@ impl FilmcraftApp {
playback: Playback { speed: 1.0, ..Default::default() },
audio: None,
hooks: HostHooks::default(),
dialog: None,
// Unsaved changes left by a session that died are offered first thing.
dialog: recovery.then_some(Dialog::Recovery),
file_dialogs: Default::default(),
auto: Default::default(),
textures: HashMap::new(),
control_rx: None,
@ -602,6 +612,11 @@ impl FilmcraftApp {
let ctx = ui.ctx().clone();
self.auto.begin_frame();
self.frames.set_context(&ctx);
self.session.poll_persistence();
if self.session.persistence.is_some() && self.session.is_dirty() {
// Keep polling the auto-save worker (status, "also save the project" results).
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
self.sync_pool();
for ev in self.session.drain_events() {
match ev {
@ -784,6 +799,12 @@ impl eframe::App for FilmcraftApp {
self.collect_screenshots(ctx);
}
fn on_exit(&mut self) {
// Flush the recovery journal and stop the auto-save worker; a session with nothing unsaved
// removes its journal, one with unsaved changes keeps it for the next launch.
self.session.shutdown();
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
if !self.fonts_ready {
ui.ctx().request_repaint();

View File

@ -65,6 +65,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("mode.export", "Export", ["File", "Export"], Some("Cmd+M")),
uic!("app.about", "About FilmCraft", ["Help"], None),
uic!("help.shortcuts", "Keyboard Shortcuts", ["Help"], None),
uic!("app.preferences.autoSave", "Auto Save…", ["Edit", "Preferences"], Some("Cmd+,")),
];
pub fn panel_command_id(p: PanelKind) -> String {
@ -157,6 +158,27 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
app.dialog = Some(crate::Dialog::Shortcuts);
return Ok(Value::Null);
}
"app.preferences" | "app.preferences.autoSave" => {
app.file_dialogs.prefs_draft = Some(app.session.prefs.auto_save.clone());
app.dialog = Some(crate::Dialog::Preferences);
return Ok(Value::Null);
}
// From menus/shortcuts (no params) these ask first; agents pass params to act directly.
"file.revert" if params.as_object().is_none_or(|m| m.is_empty()) && app.session.is_dirty() => {
if app.session.path.is_none() {
return Err("the project has not been saved yet".into());
}
app.dialog = Some(crate::Dialog::RevertConfirm);
return Ok(json!({"dialog": "revert"}));
}
"file.recover" if params.as_object().is_none_or(|m| m.is_empty()) => {
if app.session.recovery_candidates().is_empty() {
return Err("there are no unsaved changes to recover".into());
}
app.file_dialogs.recovery_choice = 0;
app.dialog = Some(crate::Dialog::Recovery);
return Ok(json!({"dialog": "recovery"}));
}
_ => {}
}
if let Some(r) = id.strip_prefix("view.playbackRes.") {

View File

@ -1,9 +1,15 @@
//! Modal dialogs (About, Keyboard Shortcuts).
//! Modal dialogs (About, Keyboard Shortcuts; Preferences/Recovery/Revert in `file_dialogs`).
use crate::{Dialog, FilmcraftApp};
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(d) = app.dialog else { return };
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
if !still_open && app.dialog == Some(d) {
app.dialog = None;
}
return;
}
let mut open = true;
match d {
Dialog::About => {
@ -32,7 +38,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
});
});
}
Dialog::NewSequence => {}
Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {}
}
if !open {
app.dialog = None;

View File

@ -0,0 +1,283 @@
//! Preferences ▸ Auto Save, the crash-recovery prompt and the Revert confirmation.
//!
//! Automation ids: `prefs.autoSave.<key>` (controls), `prefs.category.autoSave`, `prefs.reset`,
//! `prefs.cancel`, `prefs.ok`; `recovery.item.<n>`, `recovery.recover`, `recovery.discard`,
//! `recovery.later`; `revert.yes`, `revert.no`.
use egui::{Align2, Color32, CornerRadius, Frame, Margin, RichText, Stroke, Ui, vec2};
use filmcraft_engine::autosave::AutoSavePrefs;
use serde_json::json;
use crate::theme::Tokens;
use crate::{Dialog, FilmcraftApp};
/// Dialog-local state (the Preferences draft is applied on OK).
#[derive(Default)]
pub struct FileDialogState {
pub prefs_draft: Option<AutoSavePrefs>,
pub recovery_choice: usize,
}
fn modal_frame(t: &Tokens) -> Frame {
Frame::new().fill(t.panel_bg).stroke(Stroke::new(1.0, t.separator)).corner_radius(CornerRadius::same(10)).inner_margin(Margin::same(0))
}
fn button(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, label: &str, primary: bool) -> bool {
let t = app.tokens;
let text = RichText::new(label).size(13.0).color(if primary { Color32::WHITE } else { t.text });
let b = egui::Button::new(text)
.min_size(vec2(88.0, 30.0))
.corner_radius(CornerRadius::same(15))
.fill(if primary { t.accent } else { Color32::TRANSPARENT })
.stroke(if primary { Stroke::NONE } else { Stroke::new(1.5, t.text_faint) });
let r = ui.add(b);
app.auto.add(id, r.rect, label);
r.clicked()
}
/// A Premiere-style group box: 1-px rounded border with the title inset in the top edge.
fn group(ui: &mut Ui, t: &Tokens, title: &str, add: impl FnOnce(&mut Ui)) {
ui.add_space(8.0);
let r = Frame::new()
.stroke(Stroke::new(1.0, t.field_border))
.corner_radius(CornerRadius::same(4))
.inner_margin(Margin { left: 14, right: 14, top: 18, bottom: 12 })
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 10.0;
add(ui);
});
let rect = r.response.rect;
let galley = ui.painter().layout_no_wrap(title.to_string(), Tokens::ui(12.5), t.text_dim);
let pos = rect.left_top() + vec2(14.0, -galley.size().y / 2.0);
ui.painter().rect_filled(egui::Rect::from_min_size(pos - vec2(5.0, 0.0), galley.size() + vec2(10.0, 0.0)), 0.0, t.panel_bg);
ui.painter().galley(pos, galley, t.text_dim);
ui.add_space(6.0);
}
fn checkbox(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, value: &mut bool, label: &str) {
let r = ui.checkbox(value, RichText::new(label).size(13.0));
app.auto.add(id, r.rect, label);
}
fn number(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, label: &str, value: &mut u32, range: (u32, u32), unit: &str) {
ui.horizontal(|ui| {
ui.add_sized(vec2(230.0, 24.0), egui::Label::new(RichText::new(label).size(13.0)));
let r = ui.add_sized(vec2(90.0, 26.0), egui::DragValue::new(value).range(range.0..=range.1).speed(0.2));
app.auto.add(id, r.rect, label);
if !unit.is_empty() {
ui.label(RichText::new(unit).size(13.0));
}
});
}
pub fn show_preferences(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let t = app.tokens;
let mut draft = app.file_dialogs.prefs_draft.clone().unwrap_or_else(|| app.session.prefs.auto_save.clone());
let mut close = false;
let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default();
let resp = egui::Modal::new(egui::Id::new("prefs-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
ui.set_width(780.0);
// title bar
let (bar, _) = ui.allocate_exact_size(vec2(780.0, 34.0), egui::Sense::hover());
ui.painter().rect_filled(bar, CornerRadius { nw: 10, ne: 10, sw: 0, se: 0 }, t.header_bg);
ui.painter().text(bar.center(), Align2::CENTER_CENTER, "Preferences", Tokens::semibold(13.0), t.text_dim);
ui.horizontal_top(|ui| {
ui.add_space(16.0);
// category list
ui.vertical(|ui| {
ui.add_space(14.0);
let (list, _) = ui.allocate_exact_size(vec2(190.0, 420.0), egui::Sense::hover());
ui.painter().rect(list, 2.0, t.app_bg, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
let row = egui::Rect::from_min_size(list.min + vec2(2.0, 4.0), vec2(186.0, 24.0));
ui.painter().rect_filled(row, 0.0, t.row_selected);
ui.painter().text(row.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Auto Save", Tokens::ui(13.0), t.text);
app.auto.add("prefs.category.autoSave", row, "Auto Save");
});
ui.add_space(16.0);
ui.vertical(|ui| {
ui.set_width(540.0);
ui.add_space(14.0);
group(ui, &t, "Local Projects", |ui| {
checkbox(app, ui, "prefs.autoSave.enabled", &mut draft.enabled, "Automatically save projects");
ui.add_enabled_ui(draft.enabled, |ui| {
number(app, ui, "prefs.autoSave.intervalMinutes", "Automatically Save Every:", &mut draft.interval_minutes, (1, 1440), "minute(s)");
number(app, ui, "prefs.autoSave.maxVersions", "Maximum Project Versions:", &mut draft.max_versions, (1, 1000), "");
checkbox(app, ui, "prefs.autoSave.saveCurrentProject", &mut draft.save_current_project, "Auto Save also saves the current project(s)");
});
let dir = status["autoSaveDir"].as_str().unwrap_or("Auto-Save folder next to the project");
ui.label(RichText::new(format!("Auto-saves go to: {dir}")).size(11.5).color(t.text_dim));
});
group(ui, &t, "Crash Recovery", |ui| {
checkbox(app, ui, "prefs.autoSave.recoveryJournal", &mut draft.recovery_journal, "Keep a recovery copy of unsaved changes");
ui.add_enabled_ui(draft.recovery_journal, |ui| {
number(
app,
ui,
"prefs.autoSave.recoveryIntervalSeconds",
"Update it at least every:",
&mut draft.recovery_interval_seconds,
(1, 600),
"second(s)",
);
});
ui.label(
RichText::new("Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts.")
.size(11.5)
.color(t.text_dim),
);
if let Some(dir) = status["sessionDir"].as_str() {
ui.label(RichText::new(format!("Location: {dir}")).size(11.5).color(t.text_dim));
}
let last = match (status["lastAutoSaveAt"].as_str(), status["lastJournalAt"].as_str()) {
(Some(a), Some(j)) => format!("Last auto-save {a} · last recovery copy {j}"),
(Some(a), None) => format!("Last auto-save {a}"),
(None, Some(j)) => format!("Last recovery copy {j}"),
(None, None) => String::new(),
};
if !last.is_empty() {
ui.label(RichText::new(last).size(11.5).color(t.text_dim));
}
});
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.add_space(16.0);
if button(app, ui, "prefs.reset", "Reset…", false) {
draft = AutoSavePrefs::default();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(16.0);
if button(app, ui, "prefs.ok", "OK", true) {
let values = serde_json::to_value(&draft).unwrap_or_default();
let map: serde_json::Map<String, serde_json::Value> =
values.as_object().map(|m| m.iter().map(|(k, v)| (format!("autoSave.{k}"), v.clone())).collect()).unwrap_or_default();
if let Err(e) = app.session.execute("prefs.set", json!({ "values": map })) {
app.ui.status = e.to_string();
}
close = true;
}
if button(app, ui, "prefs.cancel", "Cancel", false) {
close = true;
}
});
});
ui.add_space(14.0);
});
if resp.should_close() || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
app.file_dialogs.prefs_draft = if close { None } else { Some(draft) };
!close
}
pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let t = app.tokens;
let list = app.session.execute("file.recoveryList", json!({})).unwrap_or_default();
let items = list.as_array().cloned().unwrap_or_default();
if items.is_empty() {
return false;
}
let mut choice = app.file_dialogs.recovery_choice.min(items.len() - 1);
let mut open = true;
egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
ui.set_width(520.0);
Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| {
ui.label(RichText::new("Recover Unsaved Changes").size(18.0).strong().color(t.text));
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
let c = &items[choice];
let name = c["projectName"].as_str().unwrap_or("Untitled");
let when = c["savedAt"].as_str().unwrap_or("");
let why = if c["cleanExit"].as_bool() == Some(true) {
format!("FilmCraft was closed while “{name}” had unsaved changes.")
} else {
format!("FilmCraft quit unexpectedly while “{name}” had unsaved changes.")
};
ui.label(RichText::new(why).size(13.5).color(t.text));
ui.add_space(4.0);
ui.label(RichText::new(format!("Recover unsaved changes from {when}?")).size(13.5).color(t.text));
if let Some(p) = c["projectPath"].as_str() {
ui.label(RichText::new(p).size(11.5).color(t.text_dim));
} else {
ui.label(RichText::new("The project had not been saved yet.").size(11.5).color(t.text_dim));
}
if items.len() > 1 {
ui.add_space(8.0);
ui.label(RichText::new(format!("{} sessions have unsaved changes:", items.len())).size(12.0).color(t.text_dim));
for (i, it) in items.iter().enumerate() {
let label = format!("{} — {}", it["projectName"].as_str().unwrap_or("Untitled"), it["savedAt"].as_str().unwrap_or(""));
let r = ui.radio(choice == i, RichText::new(&label).size(12.5));
app.auto.add(&format!("recovery.item.{i}"), r.rect, &label);
if r.clicked() {
choice = i;
}
}
}
ui.add_space(18.0);
let id = items[choice]["id"].as_str().unwrap_or("").to_string();
ui.horizontal(|ui| {
if button(app, ui, "recovery.discard", "Discard", false) {
if let Err(e) = app.session.execute("file.discardRecovery", json!({ "id": id })) {
app.ui.status = e.to_string();
}
choice = 0;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if button(app, ui, "recovery.recover", "Recover", true) {
match app.session.execute("file.recover", json!({ "id": id })) {
Ok(_) => open = false,
Err(e) => app.ui.status = e.to_string(),
}
}
if button(app, ui, "recovery.later", "Not Now", false) {
open = false;
}
});
});
});
});
app.file_dialogs.recovery_choice = choice;
open && !app.session.recovery_candidates().is_empty()
}
pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let t = app.tokens;
let file = app.session.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let mut open = true;
let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
ui.set_width(460.0);
Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| {
ui.label(RichText::new("Revert").size(18.0).strong().color(t.text));
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
ui.label(RichText::new(format!("Are you sure you want to discard your changes to '{file}'?")).size(13.5).color(t.text));
ui.add_space(18.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if button(app, ui, "revert.yes", "Yes", true) {
if let Err(e) = app.session.execute("file.revert", json!({})) {
app.ui.status = e.to_string();
}
open = false;
}
if button(app, ui, "revert.no", "No", false) {
open = false;
}
});
});
});
open && !r.should_close()
}
/// Draw the dialog if it is one of ours; returns Some(still open).
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context, d: Dialog) -> Option<bool> {
match d {
Dialog::Preferences => Some(show_preferences(app, ctx)),
Dialog::Recovery => Some(show_recovery(app, ctx)),
Dialog::RevertConfirm => Some(show_revert(app, ctx)),
_ => None,
}
}

View File

@ -5,6 +5,7 @@ pub mod dialogs;
pub mod effect_controls;
pub mod effects;
pub mod export_mode;
pub mod file_dialogs;
pub mod import_mode;
pub mod lumetri;
pub mod meters;

View File

@ -31,6 +31,9 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
Element ids are stable, e.g. `timeline.clip.<id>`, `timeline.track.V1.lock`, `tools.Razor`,
`project.item.<id>`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`.
Project files, auto-save, crash recovery and preferences commands (`file.recover`, `prefs.set`, …) and their
automation ids are listed in [project-files.md](project-files.md).
## MCP
`filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`)
or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,

View File

@ -78,3 +78,90 @@ one. If a step fails, the temp file is removed and the old file is untouched.
| `file.open {path}` | File ▸ Open Project… (⌘O) | returns `{schemaVersion, migrated}` |
In the UI, Save / Save As / Save a Copy without a `path` show a file dialog.
## Auto Save
Preferences ▸ Auto Save (Edit ▸ Preferences ▸ Auto Save…, ⌘,; FilmCraft ▸ Settings… on macOS) works
like Premiere's:
| Setting | Key | Default |
|---|---|---|
| Automatically save projects | `autoSave.enabled` | on |
| Automatically Save Every N minute(s) | `autoSave.intervalMinutes` | 5 (1–1440) |
| Maximum Project Versions | `autoSave.maxVersions` | 20 (1–1000) |
| Auto Save also saves the current project(s) | `autoSave.saveCurrentProject` | off |
| Keep a recovery copy of unsaved changes | `autoSave.recoveryJournal` | on |
| Update it at least every N second(s) | `autoSave.recoveryIntervalSeconds` | 5 (1–600) |
At each interval, if the project changed since the last auto-save, a copy is written to the
`Auto-Save` folder next to the project as `<name>-YYYY-MM-DD_HH-MM-SS.fcproj` (local time). An
unsaved project uses `<data dir>/Auto-Save/`. After each write the oldest copies beyond the limit are
deleted. Only files named exactly `<name>-<timestamp>.fcproj` are ever deleted. An auto-save is an
ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest
first.
Preferences are stored in `<data dir>/preferences.json`. The `prefs.*` commands read and change them.
## Crash recovery
Premiere's auto-save can lose up to one interval of work. FilmCraft also keeps a **recovery journal**:
- Whenever the project has unsaved changes, a snapshot is written to
`<data dir>/Recovery/<started>-<pid>[-<n>]/snapshot.fcproj`, plus `session.json` (time, revision, project
name and path). It is written about 1 s after the last edit, or after at most
`recoveryIntervalSeconds` while edits keep coming. Saving the project deletes the snapshot.
- Each running FilmCraft holds an OS file lock on `<session>/lock`. When the process dies (crash,
`kill -9`, power loss) the OS releases the lock.
- At startup, every other session directory whose lock can be taken and that has a snapshot is a
recovery candidate. FilmCraft asks: *"FilmCraft quit unexpectedly while 'X' had unsaved changes.
Recover unsaved changes from 2026-10-01 00:02:09?"*, with the buttons **Recover**, **Not Now** and
**Discard**. Stale directories without a snapshot are removed. Journals of running sessions are
never touched.
- Recovering loads the snapshot as an **unsaved** project with its original path, so Save writes
over the original file. The recovered state goes into the new session's own journal before the old
journal is deleted.
- A normal quit with nothing unsaved removes the session directory. Quitting with unsaved changes
keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was
closed while … had unsaved changes"). There is no "Save changes?" prompt on quit yet, so this
keeps the work.
Desktop flags: `--recover` recovers the newest candidate without asking. `--no-recover` starts
without asking; the changes stay available through File ▸ Recover Unsaved Changes…. `--data-dir
<dir>` (or `FILMCRAFT_DATA_DIR`) moves the data directory. The default is `~/Library/Application
Support/FilmCraft` on macOS, `%APPDATA%\FilmCraft` on Windows, and `$XDG_DATA_HOME/filmcraft` or
`~/.local/share/filmcraft` elsewhere.
### Cost
The UI thread only gives the worker thread an `Arc<Project>`: the immutable snapshot the engine
already keeps for undo, passed through a channel. JSON encoding and all file I/O run on the
`filmcraft-autosave` thread. Measured on the development Mac (release build) with a 2,000-clip project
(`cargo test --release -p filmcraft-format --test fixtures -- --nocapture`):
| Step | Time | Size |
|---|---|---|
| encode, compact (what is written) | 2.5 ms | 1.4 MB |
| encode, pretty (not used) | 7.1 ms | 5.6 MB |
| decode | 4.6 ms | |
| atomic write + fsync (APFS SSD) | 30 ms | |
In the running app with the demo project, a recovery write measured 0.1 ms to encode and 27 ms to
write (two fsynced files).
## Commands
| Id | |
|---|---|
| `file.recoveryList` | candidates: `id`, `projectName`, `projectPath`, `savedAt`, `revision`, `cleanExit`… |
| `file.recover {id?}` | recover (newest when no `id`). From the UI, `{}` opens the recovery dialog |
| `file.discardRecovery {id?, all?}` | delete a candidate's journal |
| `file.autoSaveNow` | write an auto-save now (if anything is unsaved) |
| `file.autoSaveStatus` | prefs, folders, last auto-save / journal times, last encode/write ms and bytes |
| `file.listAutoSaves` | auto-save files of the current project, newest first |
| `prefs.get {key?}` / `prefs.set {key, value}` or `{values:{…}}` / `prefs.reset` | preferences |
UI automation ids: `prefs.autoSave.enabled`, `prefs.autoSave.intervalMinutes`,
`prefs.autoSave.maxVersions`, `prefs.autoSave.saveCurrentProject`, `prefs.autoSave.recoveryJournal`,
`prefs.autoSave.recoveryIntervalSeconds`, `prefs.ok`, `prefs.cancel`, `prefs.reset`;
`recovery.recover`, `recovery.later`, `recovery.discard`, `recovery.item.<n>`; `revert.yes`,
`revert.no` (File ▸ Revert asks first when invoked without params).