diff options
Diffstat (limited to 'rust/crates/storage')
| -rw-r--r-- | rust/crates/storage/Cargo.toml | 16 | ||||
| -rw-r--r-- | rust/crates/storage/src/lib.rs | 256 | ||||
| -rw-r--r-- | rust/crates/storage/tests/store.rs | 276 |
3 files changed, 548 insertions, 0 deletions
diff --git a/rust/crates/storage/Cargo.toml b/rust/crates/storage/Cargo.toml new file mode 100644 index 00000000..2ac5f67a --- /dev/null +++ b/rust/crates/storage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "storage" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +transfer = { version = "0.1.0", path = "../transfer" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +chrono = { version = "0.4", features = ["serde"] } +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/storage/src/lib.rs b/rust/crates/storage/src/lib.rs new file mode 100644 index 00000000..981f3ad1 --- /dev/null +++ b/rust/crates/storage/src/lib.rs @@ -0,0 +1,256 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use scene::{Project, ProjectMetadata}; +use transfer::{ + ArchiveMediaInput, ArchivedMediaMetadata, MEDIA_ENTRY_PREFIX, ProjectArchiveError, +}; + +const PROJECT_FILE: &str = "project.json"; +const MEDIA_DIR: &str = "media"; +const MANIFEST_FILE: &str = "manifest.json"; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("project not found: {0}")] + ProjectNotFound(String), + #[error("project already exists: {0}")] + ProjectExists(String), + #[error("media not found: {0}")] + MediaNotFound(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Archive(#[from] ProjectArchiveError), +} + +pub type Result<T, E = StorageError> = std::result::Result<T, E>; + +pub struct ProjectStore { + root: PathBuf, +} + +impl ProjectStore { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn project_dir(&self, project_id: &str) -> PathBuf { + self.root.join(project_id) + } + + pub fn create_project(&self, project: &Project) -> Result<()> { + let dir = self.project_dir(&project.metadata.id); + if dir.exists() { + return Err(StorageError::ProjectExists(project.metadata.id.clone())); + } + fs::create_dir_all(dir.join(MEDIA_DIR))?; + self.save_project(project) + } + + pub fn save_project(&self, project: &Project) -> Result<()> { + let dir = self.project_dir(&project.metadata.id); + fs::create_dir_all(dir.join(MEDIA_DIR))?; + let json = serde_json::to_vec_pretty(project)?; + write_atomic(&dir.join(PROJECT_FILE), &json) + } + + pub fn load_project(&self, project_id: &str) -> Result<Project> { + let path = self.project_dir(project_id).join(PROJECT_FILE); + let bytes = fs::read(&path).map_err(|error| match error.kind() { + ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()), + _ => StorageError::Io(error), + })?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub fn delete_project(&self, project_id: &str) -> Result<()> { + let dir = self.project_dir(project_id); + fs::remove_dir_all(&dir).map_err(|error| match error.kind() { + ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()), + _ => StorageError::Io(error), + }) + } + + pub fn list_projects(&self) -> Result<Vec<ProjectMetadata>> { + fs::create_dir_all(&self.root)?; + let mut metadata = Vec::new(); + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path().join(PROJECT_FILE); + match fs::read(&path) { + Ok(bytes) => { + if let Ok(project) = serde_json::from_slice::<Project>(&bytes) { + metadata.push(project.metadata); + } + } + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + } + } + metadata.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(metadata) + } + + pub fn add_media( + &self, + project_id: &str, + metadata: &ArchivedMediaMetadata, + data: &[u8], + ) -> Result<()> { + let media_dir = self.media_dir(project_id)?; + let mut manifest = self.read_manifest(project_id)?; + if let Some(existing) = manifest.iter().find(|item| item.id == metadata.id) { + let previous = media_dir.join(media_file_name(existing)); + let next = media_dir.join(media_file_name(metadata)); + if previous != next { + let _ = fs::remove_file(previous); + } + manifest.retain(|item| item.id != metadata.id); + } + write_atomic(&media_dir.join(media_file_name(metadata)), data)?; + manifest.push(metadata.clone()); + self.write_manifest(project_id, &manifest) + } + + pub fn media_path(&self, project_id: &str, media_id: &str) -> Result<PathBuf> { + let media_dir = self.media_dir(project_id)?; + let manifest = self.read_manifest(project_id)?; + let metadata = manifest + .iter() + .find(|item| item.id == media_id) + .ok_or_else(|| StorageError::MediaNotFound(media_id.to_string()))?; + Ok(media_dir.join(media_file_name(metadata))) + } + + pub fn delete_media(&self, project_id: &str, media_id: &str) -> Result<()> { + let media_dir = self.media_dir(project_id)?; + let mut manifest = self.read_manifest(project_id)?; + let Some(position) = manifest.iter().position(|item| item.id == media_id) else { + return Err(StorageError::MediaNotFound(media_id.to_string())); + }; + let metadata = manifest.remove(position); + let path = media_dir.join(media_file_name(&metadata)); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + self.write_manifest(project_id, &manifest) + } + + pub fn list_media(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> { + self.media_dir(project_id)?; + self.read_manifest(project_id) + } + + pub fn export_ocp(&self, project_id: &str) -> Result<Vec<u8>> { + let project = self.load_project(project_id)?; + let project_json = serde_json::to_string(&project)?; + let media_dir = self.media_dir(project_id)?; + let manifest = self.read_manifest(project_id)?; + let mut media = Vec::with_capacity(manifest.len()); + for metadata in manifest { + let data = fs::read(media_dir.join(media_file_name(&metadata)))?; + media.push(ArchiveMediaInput { metadata, data }); + } + Ok(transfer::build_project_archive(&project_json, media)?) + } + + pub fn import_ocp(&self, bytes: &[u8]) -> Result<String> { + let parsed = transfer::parse_project_archive(bytes)?; + let mut project: Project = + serde_json::from_value(serde_json::Value::Object(parsed.project.clone()))?; + + let base_id = project.metadata.id.clone(); + let mut candidate = base_id.clone(); + let mut suffix = 1; + while self.project_dir(&candidate).exists() { + candidate = format!("{base_id}-{suffix}"); + suffix += 1; + } + project.metadata.id = candidate.clone(); + + let import = (|| -> Result<()> { + self.create_project(&project)?; + let media_dir = self.media_dir(&candidate)?; + let mut manifest = Vec::with_capacity(parsed.media.len()); + for entry in &parsed.media { + let data = parsed.read_media_data(&entry.entry)?; + let file_name = media_file_name(&entry.metadata); + write_atomic(&media_dir.join(file_name), data)?; + manifest.push(entry.metadata.clone()); + } + self.write_manifest(&candidate, &manifest) + })(); + + if let Err(error) = import { + let _ = fs::remove_dir_all(self.project_dir(&candidate)); + return Err(error); + } + Ok(candidate) + } + + fn media_dir(&self, project_id: &str) -> Result<PathBuf> { + let dir = self.project_dir(project_id); + if !dir.is_dir() { + return Err(StorageError::ProjectNotFound(project_id.to_string())); + } + Ok(dir.join(MEDIA_DIR)) + } + + fn read_manifest(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> { + let path = self + .project_dir(project_id) + .join(MEDIA_DIR) + .join(MANIFEST_FILE); + match fs::read(&path) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error.into()), + } + } + + fn write_manifest( + &self, + project_id: &str, + manifest: &[ArchivedMediaMetadata], + ) -> Result<()> { + let json = serde_json::to_vec_pretty(manifest)?; + write_atomic( + &self + .project_dir(project_id) + .join(MEDIA_DIR) + .join(MANIFEST_FILE), + &json, + ) + } +} + +fn media_file_name(metadata: &ArchivedMediaMetadata) -> String { + transfer::build_media_entry_name(&metadata.id, &metadata.name) + .trim_start_matches(MEDIA_ENTRY_PREFIX) + .to_string() +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let mut tmp_name = path + .file_name() + .map(std::ffi::OsString::from) + .unwrap_or_else(|| std::ffi::OsString::from("tmp")); + tmp_name.push(".tmp"); + let tmp = path.with_file_name(tmp_name); + fs::write(&tmp, data)?; + fs::rename(&tmp, path)?; + Ok(()) +} diff --git a/rust/crates/storage/tests/store.rs b/rust/crates/storage/tests/store.rs new file mode 100644 index 00000000..696ddde8 --- /dev/null +++ b/rust/crates/storage/tests/store.rs @@ -0,0 +1,276 @@ +use chrono::{DateTime, Utc}; +use scene::{ + Background, BaseTimelineElement, CanvasSize, Element, Project, ProjectMetadata, + ProjectSettings, Scene, SceneTracks, Track, VideoElement, +}; +use storage::ProjectStore; +use tempfile::TempDir; +use time::{FrameRate, MediaTime}; +use transfer::ArchivedMediaMetadata; + +fn date(day: u8) -> DateTime<Utc> { + DateTime::parse_from_rfc3339(&format!("2026-01-{day:02}T00:00:00Z")) + .unwrap() + .with_timezone(&Utc) +} + +fn seconds(value: f64) -> MediaTime { + MediaTime::from_seconds_f64(value).unwrap() +} + +fn sample_project(id: &str) -> Project { + let scene_id = format!("{id}-scene"); + let element = Element::Video(VideoElement { + base: BaseTimelineElement { + id: "element-1".to_string(), + name: "Clip".to_string(), + duration: seconds(5.0), + start_time: MediaTime::ZERO, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: Some(seconds(10.0)), + animations: None, + params: Default::default(), + }, + media_id: "media-1".to_string(), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }); + let scene = Scene { + id: scene_id.clone(), + name: "Main scene".to_string(), + is_main: true, + tracks: SceneTracks { + overlay: vec![], + main: Track::Video { + id: "track-1".to_string(), + name: "Main".to_string(), + elements: vec![element], + muted: false, + hidden: false, + }, + audio: vec![], + }, + bookmarks: vec![], + created_at: date(1), + updated_at: date(1), + }; + Project { + metadata: ProjectMetadata { + id: id.to_string(), + name: "Sample Project".to_string(), + thumbnail: None, + duration: seconds(5.0), + created_at: date(1), + updated_at: date(2), + }, + scenes: vec![scene], + current_scene_id: scene_id, + settings: ProjectSettings { + fps: FrameRate::FPS_30, + canvas_size: CanvasSize { + width: 1920, + height: 1080, + }, + canvas_size_mode: None, + last_custom_canvas_size: None, + original_canvas_size: None, + background: Background::Color { + color: "#000000".to_string(), + }, + }, + version: 14, + timeline_view_state: None, + } +} + +fn sample_media(id: &str, name: &str) -> ArchivedMediaMetadata { + ArchivedMediaMetadata { + id: id.to_string(), + name: name.to_string(), + media_type: "video".to_string(), + file_type: "video/mp4".to_string(), + last_modified: 1_700_000_000_000.0, + width: Some(1920.0), + height: Some(1080.0), + duration: Some(5.0), + ephemeral: None, + thumbnail_url: None, + } +} + +fn store() -> (TempDir, ProjectStore) { + let dir = TempDir::new().unwrap(); + let store = ProjectStore::new(dir.path().join("projects")); + (dir, store) +} + +#[test] +fn project_round_trip() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + + store.create_project(&project).unwrap(); + assert!(store.create_project(&project).is_err()); + + let mut updated = project.clone(); + updated.metadata.name = "Renamed Project".to_string(); + store.save_project(&updated).unwrap(); + + let listed = store.list_projects().unwrap(); + assert_eq!(listed, vec![updated.metadata.clone()]); + + let loaded = store.load_project("project-1").unwrap(); + assert_eq!(loaded, updated); + + let raw = std::fs::read_to_string( + store.project_dir("project-1").join("project.json"), + ) + .unwrap(); + assert!(raw.contains("\"currentSceneId\"")); + assert!(raw.contains("\"createdAt\"")); +} + +#[test] +fn list_projects_sorts_by_updated_descending() { + let (_dir, store) = store(); + store.create_project(&sample_project("project-old")).unwrap(); + let mut newer = sample_project("project-new"); + newer.metadata.updated_at = date(20); + store.create_project(&newer).unwrap(); + + let listed = store.list_projects().unwrap(); + let ids: Vec<&str> = listed.iter().map(|meta| meta.id.as_str()).collect(); + assert_eq!(ids, vec!["project-new", "project-old"]); +} + +#[test] +fn media_round_trip() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + + let metadata = sample_media("media-1", "my clip.mp4"); + let bytes = b"fake video bytes"; + store.add_media("project-1", &metadata, bytes).unwrap(); + + let listed = store.list_media("project-1").unwrap(); + assert_eq!(listed, vec![metadata.clone()]); + + let path = store.media_path("project-1", "media-1").unwrap(); + assert_eq!( + path.file_name().unwrap().to_str().unwrap(), + "media-1-my_clip.mp4" + ); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + let manifest = std::fs::read_to_string( + store + .project_dir("project-1") + .join("media") + .join("manifest.json"), + ) + .unwrap(); + let parsed: Vec<ArchivedMediaMetadata> = serde_json::from_str(&manifest).unwrap(); + assert_eq!(parsed, vec![metadata.clone()]); + assert!(!parsed[0].media_type.is_empty()); + + store.delete_media("project-1", "media-1").unwrap(); + assert!(store.list_media("project-1").unwrap().is_empty()); + assert!(store.media_path("project-1", "media-1").is_err()); + assert!(!path.exists()); +} + +#[test] +fn add_media_replaces_existing_id() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + + let first = sample_media("media-1", "first.mp4"); + store.add_media("project-1", &first, b"first").unwrap(); + let second = sample_media("media-1", "second.mp4"); + store.add_media("project-1", &second, b"second").unwrap(); + + let listed = store.list_media("project-1").unwrap(); + assert_eq!(listed, vec![second]); + let path = store.media_path("project-1", "media-1").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + let media_dir = store.project_dir("project-1").join("media"); + let files: Vec<_> = std::fs::read_dir(&media_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(files.len(), 2); +} + +#[test] +fn export_import_round_trip() { + let (_dir_a, source) = store(); + let project = sample_project("project-1"); + source.create_project(&project).unwrap(); + let metadata = sample_media("media-1", "my clip.mp4"); + let bytes = b"fake video bytes"; + source.add_media("project-1", &metadata, bytes).unwrap(); + + let archive = source.export_ocp("project-1").unwrap(); + + let (_dir_b, target) = store(); + let imported_id = target.import_ocp(&archive).unwrap(); + assert_eq!(imported_id, "project-1"); + + let loaded = target.load_project(&imported_id).unwrap(); + assert_eq!(loaded, project); + + let listed = target.list_media(&imported_id).unwrap(); + assert_eq!(listed, vec![metadata]); + let path = target.media_path(&imported_id, "media-1").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn delete_project_removes_directory() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + store + .add_media("project-1", &sample_media("media-1", "clip.mp4"), b"data") + .unwrap(); + + let project_dir = store.project_dir("project-1"); + assert!(project_dir.is_dir()); + + store.delete_project("project-1").unwrap(); + assert!(!project_dir.exists()); + assert!(store.load_project("project-1").is_err()); + assert!(store.list_projects().unwrap().is_empty()); + assert!(store.delete_project("project-1").is_err()); +} + +#[test] +fn import_id_collision_gets_new_id() { + let (_dir_a, source) = store(); + let project = sample_project("project-1"); + source.create_project(&project).unwrap(); + let archive = source.export_ocp("project-1").unwrap(); + + let (_dir_b, target) = store(); + let first = target.import_ocp(&archive).unwrap(); + let second = target.import_ocp(&archive).unwrap(); + + assert_eq!(first, "project-1"); + assert_ne!(first, second); + assert_eq!(second, "project-1-1"); + + let first_project = target.load_project(&first).unwrap(); + let second_project = target.load_project(&second).unwrap(); + assert_eq!(first_project.metadata.id, first); + assert_eq!(second_project.metadata.id, second); + assert_eq!(second_project.scenes, first_project.scenes); + + let listed = target.list_projects().unwrap(); + assert_eq!(listed.len(), 2); +} |
