Skip to main content

edit/
persist.rs

1//! Project persistence — the `.screenproj` file format (ED.23 / M-EDIT).
2//!
3//! An [`EditProject`] is "never cut the negative" all the way down: it holds
4//! only value types — segment ranges, zoom windows, framing config, a source
5//! reference — so saving a project is just serializing that decision list to
6//! JSON, and reopening it is parsing the JSON back. No media is copied; the
7//! `.screenproj` sits beside the recording and points at it. Because the
8//! whole model is `serde`, save↔load is a pure, lossless round-trip — proven
9//! here without touching the filesystem.
10//!
11//! The file carries a [`SCHEMA_VERSION`] so a
12//! future format change can migrate rather than mis-parse.
13
14use crate::project::{EditProject, SCHEMA_VERSION};
15
16/// File extension for a saved editor project.
17pub const SCREENPROJ_EXTENSION: &str = "screenproj";
18
19/// Serialize a project to its `.screenproj` JSON (pretty-printed so the file
20/// is human-readable + diff-friendly).
21///
22/// # Errors
23///
24/// Returns a message if serialization fails (not expected for valid data).
25pub fn to_screenproj(project: &EditProject) -> Result<String, String> {
26    serde_json::to_string_pretty(project).map_err(|e| format!("serialize project: {e}"))
27}
28
29/// Parse a project from its `.screenproj` JSON.
30///
31/// # Errors
32///
33/// Returns a message if the JSON is malformed or doesn't match the schema.
34pub fn from_screenproj(json: &str) -> Result<EditProject, String> {
35    let project: EditProject =
36        serde_json::from_str(json).map_err(|e| format!("parse project: {e}"))?;
37    if project.schema_version != SCHEMA_VERSION {
38        return Err(format!(
39            "unsupported .screenproj schema version {} (this build reads {SCHEMA_VERSION})",
40            project.schema_version
41        ));
42    }
43    Ok(project)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::clip::ClipRef;
50    use crate::ops::EditOp;
51    use crate::project::SCHEMA_VERSION;
52    use crate::zoom::{ZoomId, ZoomSegment};
53    use std::path::PathBuf;
54
55    #[test]
56    fn round_trips_an_edited_project() {
57        let mut p = EditProject::from_recording(ClipRef::new(
58            PathBuf::from("/tmp/rec.mp4"),
59            1920,
60            1080,
61            30,
62            900,
63        ));
64        // Non-trivial edits so the round-trip exercises real state, not just
65        // the default shell: a split, a speed change, and a zoom.
66        p.apply(&EditOp::Split { at: 300 }).unwrap();
67        p.apply(&EditOp::SetSpeed {
68            index: 0,
69            timescale: 2.0,
70        })
71        .unwrap();
72        p.apply(&EditOp::AddZoom {
73            zoom: ZoomSegment::manual(ZoomId(0), 100, 200, 1.6),
74        })
75        .unwrap();
76
77        let json = to_screenproj(&p).expect("serialize");
78        let back = from_screenproj(&json).expect("deserialize");
79        assert_eq!(back, p, "project round-trips identically");
80        assert_eq!(back.schema_version, SCHEMA_VERSION);
81    }
82
83    #[test]
84    fn malformed_json_errors() {
85        assert!(from_screenproj("not valid json {").is_err());
86        // Well-formed JSON of the wrong shape (an array, not the project
87        // object) is rejected too.
88        assert!(from_screenproj("[1, 2, 3]").is_err());
89    }
90
91    #[test]
92    fn screenproj_is_pretty_and_versioned() {
93        let p = EditProject::from_recording(ClipRef::new(
94            PathBuf::from("/tmp/a.mp4"),
95            640,
96            480,
97            30,
98            100,
99        ));
100        let json = to_screenproj(&p).unwrap();
101        assert!(json.contains('\n'), "pretty-printed (multi-line)");
102        assert!(json.contains("schema_version"));
103    }
104
105    #[test]
106    fn rejects_a_future_schema_version() {
107        let p =
108            EditProject::from_recording(ClipRef::new(PathBuf::from("/tmp/a.mp4"), 64, 64, 30, 10));
109        let json = to_screenproj(&p).unwrap();
110        assert!(from_screenproj(&json).is_ok(), "current version loads");
111        // A newer on-disk version is refused (explicit error, not mis-parse).
112        let bumped = json.replace("\"schema_version\": 1", "\"schema_version\": 999");
113        assert!(from_screenproj(&bumped).is_err(), "future version rejected");
114    }
115}