303 lines
12 KiB
Rust
303 lines
12 KiB
Rust
//! `.fcproj` project files.
|
||
//!
|
||
//! A project file is UTF-8 JSON with an explicit schema version:
|
||
//!
|
||
//! ```json
|
||
//! { "format": "filmcraft.project", "schema_version": 2, "generator": "FilmCraft 0.1.0", "project": { … } }
|
||
//! ```
|
||
//!
|
||
//! - **Versioning.** [`SCHEMA_VERSION`] is the version this build writes. Older files are upgraded on
|
||
//! load by a chain of single-step migrations ([`MIGRATIONS`]: v1→v2, v2→v3, …) operating on the
|
||
//! JSON document, so every historical shape only needs one small function. Newer files are refused
|
||
//! with [`FormatError::TooNew`] instead of being half-read (and later overwritten with data loss).
|
||
//! - **Atomic saves.** [`atomic_write`] writes a temp file in the destination directory, fsyncs it,
|
||
//! renames it over the target and fsyncs the directory: a crash or power cut during a save leaves
|
||
//! either the old file or the new one, never a torn mix.
|
||
//! - **Auto-save naming/rotation** lives in [`autosave`] (`<name>-YYYY-MM-DD_HH-MM-SS.fcproj` in an
|
||
//! `Auto-Save` folder next to the project, oldest pruned beyond the version limit).
|
||
//!
|
||
//! Schema history:
|
||
//! - **v1** (M0–M11): the bare serialized `Project` object with a `"version": 1` field.
|
||
//! - **v2** (M11.1): the envelope above; `Project.version` dropped (the envelope carries it).
|
||
//! - **v3** (M10.2): graphic clips (`ItemKind::Graphic`, text/shape layers). The shape of older
|
||
//! data is unchanged (no-op step); the bump makes builds without graphics refuse such files
|
||
//! cleanly instead of failing to parse them.
|
||
//! - **v4** (M7.4): Essential Sound (clip audio types and settings, `EffectInstance::essential`).
|
||
//! No-op step; older builds would otherwise drop these fields silently when saving.
|
||
|
||
pub mod atomic;
|
||
pub mod autosave;
|
||
|
||
use filmcraft_project::Project;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
|
||
pub use atomic::atomic_write;
|
||
|
||
/// The `format` tag written in every envelope.
|
||
pub const FORMAT_ID: &str = "filmcraft.project";
|
||
|
||
/// A migration upgrades a whole document from schema `n` to `n + 1`.
|
||
pub type Migration = fn(Value) -> Result<Value, String>;
|
||
|
||
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
|
||
/// edit a shipped one.
|
||
pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4];
|
||
|
||
/// The schema version this build writes (and the newest it reads).
|
||
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
|
||
|
||
/// Oldest schema this build can still upgrade.
|
||
pub const OLDEST_SCHEMA: u32 = 1;
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum FormatError {
|
||
#[error("not a FilmCraft project: {0}")]
|
||
NotAProject(String),
|
||
#[error(
|
||
"this project was saved by a newer version of FilmCraft (project schema v{found}); this build reads up to v{supported}. Update FilmCraft to open it."
|
||
)]
|
||
TooNew { found: u32, supported: u32 },
|
||
#[error("project schema v{found} is too old for this build (oldest supported: v{oldest})")]
|
||
TooOld { found: u32, oldest: u32 },
|
||
#[error("upgrading project from schema v{from} to v{}: {msg}", from + 1)]
|
||
Migration { from: u32, msg: String },
|
||
#[error("project file is damaged: {0}")]
|
||
Corrupt(String),
|
||
}
|
||
|
||
/// A project read from disk.
|
||
#[derive(Debug)]
|
||
pub struct Loaded {
|
||
pub project: Project,
|
||
/// Schema version found in the file (before migration).
|
||
pub schema_version: u32,
|
||
/// The `generator` string of the writer, if recorded.
|
||
pub generator: Option<String>,
|
||
}
|
||
|
||
impl Loaded {
|
||
/// True when the file was written in an older schema and upgraded on load.
|
||
pub fn migrated(&self) -> bool {
|
||
self.schema_version < SCHEMA_VERSION
|
||
}
|
||
}
|
||
|
||
/// Writer identification stored in the envelope.
|
||
pub fn generator() -> String {
|
||
format!("FilmCraft {}", env!("CARGO_PKG_VERSION"))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct EnvelopeOut<'a> {
|
||
format: &'static str,
|
||
schema_version: u32,
|
||
generator: String,
|
||
project: &'a Project,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct EnvelopeIn {
|
||
#[serde(default)]
|
||
generator: Option<String>,
|
||
project: Project,
|
||
}
|
||
|
||
/// Just enough of a document to know which schema it is.
|
||
#[derive(Deserialize)]
|
||
struct Probe {
|
||
#[serde(default)]
|
||
format: Option<String>,
|
||
#[serde(default)]
|
||
schema_version: Option<u32>,
|
||
/// Bare (pre-envelope) project: its `version` field.
|
||
#[serde(default)]
|
||
version: Option<u32>,
|
||
#[serde(default)]
|
||
items: Option<serde::de::IgnoredAny>,
|
||
#[serde(default)]
|
||
root: Option<serde::de::IgnoredAny>,
|
||
}
|
||
|
||
/// Serialize a project in the current schema. `pretty` = indented (human-diffable; used for the
|
||
/// project file itself), compact otherwise (recovery snapshots).
|
||
pub fn encode(project: &Project, pretty: bool) -> Vec<u8> {
|
||
let env = EnvelopeOut { format: FORMAT_ID, schema_version: SCHEMA_VERSION, generator: generator(), project };
|
||
let r = if pretty { serde_json::to_vec_pretty(&env) } else { serde_json::to_vec(&env) };
|
||
// Serializing plain data (string keys, finite numbers or null) cannot fail.
|
||
r.unwrap_or_default()
|
||
}
|
||
|
||
/// Detect the schema version of a parsed document.
|
||
pub fn detect_version(doc: &Value) -> Result<u32, FormatError> {
|
||
let probe: Probe = serde_json::from_value(doc.clone()).map_err(|e| FormatError::NotAProject(e.to_string()))?;
|
||
probe_version(&probe)
|
||
}
|
||
|
||
fn probe_version(p: &Probe) -> Result<u32, FormatError> {
|
||
if let Some(v) = p.schema_version {
|
||
if let Some(f) = &p.format
|
||
&& f != FORMAT_ID
|
||
{
|
||
return Err(FormatError::NotAProject(format!("format is `{f}`, expected `{FORMAT_ID}`")));
|
||
}
|
||
return Ok(v);
|
||
}
|
||
// Before the envelope (v1) a file was the bare Project object with `version`.
|
||
// Its `version` was 1, or 2 on builds that briefly bumped it when caption tracks were added
|
||
// (same shape; the new field loads with its default). Anything higher is from the future.
|
||
if p.items.is_some() && p.root.is_some() {
|
||
return Ok(match p.version.unwrap_or(1) {
|
||
0..=2 => 1,
|
||
v => v,
|
||
});
|
||
}
|
||
Err(FormatError::NotAProject("no `schema_version` and not a legacy project".into()))
|
||
}
|
||
|
||
/// Read a project file (any supported schema).
|
||
pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||
let probe: Probe = serde_json::from_slice(bytes)
|
||
.map_err(|e| if e.is_syntax() || e.is_eof() { FormatError::Corrupt(e.to_string()) } else { FormatError::NotAProject(e.to_string()) })?;
|
||
let found = probe_version(&probe)?;
|
||
check_supported(found)?;
|
||
if found == SCHEMA_VERSION {
|
||
// Fast path: deserialize straight into the model.
|
||
let env: EnvelopeIn = serde_json::from_slice(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
return Ok(Loaded { project: env.project, schema_version: found, generator: env.generator });
|
||
}
|
||
let doc: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
let doc = migrate_with(MIGRATIONS, doc, found)?;
|
||
let env: EnvelopeIn = serde_json::from_value(doc).map_err(|e| FormatError::Corrupt(format!("after upgrading from schema v{found}: {e}")))?;
|
||
Ok(Loaded { project: env.project, schema_version: found, generator: env.generator })
|
||
}
|
||
|
||
fn check_supported(found: u32) -> Result<(), FormatError> {
|
||
if found > SCHEMA_VERSION {
|
||
return Err(FormatError::TooNew { found, supported: SCHEMA_VERSION });
|
||
}
|
||
if found < OLDEST_SCHEMA {
|
||
return Err(FormatError::TooOld { found, oldest: OLDEST_SCHEMA });
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Run `table` from schema `from` up to `table.len() + 1`, stamping `schema_version` after each step.
|
||
pub fn migrate_with(table: &[Migration], mut doc: Value, from: u32) -> Result<Value, FormatError> {
|
||
let target = table.len() as u32 + 1;
|
||
if from > target {
|
||
return Err(FormatError::TooNew { found: from, supported: target });
|
||
}
|
||
if from == 0 {
|
||
return Err(FormatError::TooOld { found: 0, oldest: 1 });
|
||
}
|
||
for v in from..target {
|
||
let step = table[(v - 1) as usize];
|
||
doc = step(doc).map_err(|msg| FormatError::Migration { from: v, msg })?;
|
||
match doc.as_object_mut() {
|
||
Some(o) => {
|
||
o.insert("schema_version".into(), Value::from(v + 1));
|
||
}
|
||
None => return Err(FormatError::Migration { from: v, msg: "migration produced a non-object document".into() }),
|
||
}
|
||
}
|
||
Ok(doc)
|
||
}
|
||
|
||
/// v1 → v2: wrap the bare project in the envelope and drop the per-project `version` field.
|
||
fn v1_to_v2(doc: Value) -> Result<Value, String> {
|
||
let Value::Object(mut project) = doc else { return Err("expected a JSON object".into()) };
|
||
project.remove("version");
|
||
Ok(serde_json::json!({
|
||
"format": FORMAT_ID,
|
||
"schema_version": 2,
|
||
"generator": "FilmCraft (schema v1)",
|
||
"project": Value::Object(project),
|
||
}))
|
||
}
|
||
|
||
/// v2 → v3: graphic clips added; existing data needs no change.
|
||
fn v2_to_v3(doc: Value) -> Result<Value, String> {
|
||
Ok(doc)
|
||
}
|
||
|
||
/// v3 → v4: Essential Sound fields added; existing data needs no change.
|
||
fn v3_to_v4(doc: Value) -> Result<Value, String> {
|
||
Ok(doc)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn schema_version_matches_table() {
|
||
assert_eq!(SCHEMA_VERSION, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_current() {
|
||
let p = Project::new("Round Trip");
|
||
for pretty in [true, false] {
|
||
let b = encode(&p, pretty);
|
||
let l = decode(&b).unwrap();
|
||
assert_eq!(l.project, p);
|
||
assert_eq!(l.schema_version, SCHEMA_VERSION);
|
||
assert!(!l.migrated());
|
||
assert_eq!(l.generator.as_deref(), Some(generator().as_str()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_has_explicit_schema_version() {
|
||
let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap();
|
||
assert_eq!(v["schema_version"], SCHEMA_VERSION);
|
||
assert_eq!(v["format"], FORMAT_ID);
|
||
assert!(v["project"]["items"].is_object());
|
||
assert!(v["project"].get("version").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_newer() {
|
||
let mut v: Value = serde_json::from_slice(&encode(&Project::new("x"), false)).unwrap();
|
||
v["schema_version"] = Value::from(SCHEMA_VERSION + 1);
|
||
let e = decode(&serde_json::to_vec(&v).unwrap()).unwrap_err();
|
||
assert!(matches!(e, FormatError::TooNew { .. }));
|
||
assert!(e.to_string().contains("newer version of FilmCraft"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_foreign_and_damaged() {
|
||
assert!(matches!(decode(br#"{"hello": 1}"#), Err(FormatError::NotAProject(_))));
|
||
assert!(matches!(decode(br#"{"format":"other","schema_version":1}"#), Err(FormatError::NotAProject(_))));
|
||
let b = encode(&Project::new("x"), false);
|
||
assert!(matches!(decode(&b[..b.len() / 2]), Err(FormatError::Corrupt(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn chain_runs_each_step_in_order() {
|
||
fn a(mut d: Value) -> Result<Value, String> {
|
||
d["trail"] = Value::from(format!("{}a", d["trail"].as_str().unwrap_or("")));
|
||
Ok(d)
|
||
}
|
||
fn b(mut d: Value) -> Result<Value, String> {
|
||
d["trail"] = Value::from(format!("{}b", d["trail"].as_str().unwrap_or("")));
|
||
Ok(d)
|
||
}
|
||
fn fail(_: Value) -> Result<Value, String> {
|
||
Err("boom".into())
|
||
}
|
||
let table: &[Migration] = &[a, b, a];
|
||
let out = migrate_with(table, serde_json::json!({}), 1).unwrap();
|
||
assert_eq!(out["trail"], "aba");
|
||
assert_eq!(out["schema_version"], 4);
|
||
let out = migrate_with(table, serde_json::json!({}), 3).unwrap();
|
||
assert_eq!(out["trail"], "a");
|
||
let out = migrate_with(table, serde_json::json!({"x": 1}), 4).unwrap();
|
||
assert_eq!(out, serde_json::json!({"x": 1}));
|
||
assert!(matches!(migrate_with(table, serde_json::json!({}), 5), Err(FormatError::TooNew { found: 5, supported: 4 })));
|
||
let e = migrate_with(&[a, fail], serde_json::json!({}), 1).unwrap_err();
|
||
assert!(matches!(e, FormatError::Migration { from: 2, .. }), "{e}");
|
||
}
|
||
}
|