summaryrefslogtreecommitdiff
path: root/rust/crates/storage
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/storage')
-rw-r--r--rust/crates/storage/Cargo.toml16
-rw-r--r--rust/crates/storage/src/lib.rs256
-rw-r--r--rust/crates/storage/tests/store.rs276
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);
+}