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:
parent
ba4a04e031
commit
516978b4d4
|
|
@ -1288,6 +1288,7 @@ dependencies = [
|
|||
name = "filmcraft"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms);
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue