diff --git a/Cargo.lock b/Cargo.lock index e0909a1b..79863885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,6 +1288,7 @@ dependencies = [ name = "filmcraft" version = "0.1.0" dependencies = [ + "chrono", "cpal", "eframe", "egui", diff --git a/ROADMAP.md b/ROADMAP.md index 245a1e2d..fda76006 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index ed2fcb74..2702615f 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -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" diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index f2de5865..54fe6fb3 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -1,16 +1,25 @@ //! FilmCraft desktop app. //! -//! Usage: `filmcraft [--control ] [--demo] [project.fcproj | media files…]` +//! Usage: `filmcraft [--control ] [--demo|--empty] [--recover|--no-recover] [--data-dir ] +//! [project.fcproj | media files…]` //! //! `--control ` (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 = 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 = 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) +} diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index e9a562a5..3f9a6543 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -26,6 +26,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { 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(), diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 00000000..436841ab --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -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 `-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 `/Recovery//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 +//! `/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