diff --git a/crates/clipbin-core/src/store.rs b/crates/clipbin-core/src/store.rs index 5efffda..7f75d00 100644 --- a/crates/clipbin-core/src/store.rs +++ b/crates/clipbin-core/src/store.rs @@ -10,7 +10,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Segment { pub path: PathBuf, pub start: Duration, @@ -42,6 +42,8 @@ pub struct Metadata { pub segments: Vec, } +const METADATA_FILE: &str = "session.json"; + #[derive(Clone)] pub struct Store { pub root: PathBuf, @@ -105,7 +107,7 @@ impl Store { } pub fn save(&self, metadata: &Metadata) -> io::Result<()> { atomic_json( - &self.directory(metadata.session.id).join("session.json"), + &self.directory(metadata.session.id).join(METADATA_FILE), metadata, ) } @@ -142,7 +144,7 @@ impl Store { }; let mut sessions = vec![]; for entry in entries { - let path = entry?.path().join("session.json"); + let path = entry?.path().join(METADATA_FILE); if path.exists() { let bytes = fs::read(&path)?; sessions.push( @@ -166,6 +168,18 @@ impl Store { } Ok(()) } + /// One session's metadata, with segment paths resolved against its directory. + pub fn metadata(&self, id: u64) -> io::Result { + let directory = self.directory(id); + let mut metadata: Metadata = + serde_json::from_slice(&fs::read(directory.join(METADATA_FILE))?) + .map_err(io::Error::other)?; + for segment in &mut metadata.segments { + segment.path = directory.join(&segment.path); + } + Ok(metadata) + } + pub fn library(&self, budget: u64) -> io::Result { let mut library = Library::empty(budget); for metadata in self.read()? { @@ -190,7 +204,66 @@ impl Store { } } +/// A point in a segment file: the file and the offset into it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Located<'a> { + pub index: usize, + pub segment: &'a Segment, + /// Time within the file. Each file's timestamps start at zero. + pub offset: Duration, +} + +/// The part of one segment that falls inside a requested range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Piece<'a> { + pub index: usize, + pub segment: &'a Segment, + /// Range within the file, from its own zero. + pub from: Duration, + pub to: Duration, + /// Where `from` sits on the session timeline. + pub session_start: Duration, +} + impl Metadata { + /// Segments that hold footage, in session order. + fn playable(&self) -> impl Iterator { + self.segments + .iter() + .enumerate() + .filter(|(_, segment)| segment.available && segment.end > segment.start) + } + + /// Finds the footage at session time `time`. `None` inside gaps (missing or + /// cleaned-up time) and beyond the last footage. + pub fn locate(&self, time: Duration) -> Option> { + self.playable() + .find(|(_, segment)| segment.start <= time && time < segment.end) + .map(|(index, segment)| Located { + index, + segment, + offset: time - segment.start, + }) + } + + /// The footage between `start` and `end`, as pieces of segment files in + /// order. Gaps simply produce no pieces. + pub fn covering(&self, start: Duration, end: Duration) -> Vec> { + self.playable() + .filter(|(_, segment)| segment.start < end && start < segment.end) + .map(|(index, segment)| { + let session_start = start.max(segment.start); + Piece { + index, + segment, + from: session_start - segment.start, + to: end.min(segment.end) - segment.start, + session_start, + } + }) + .collect() + } + pub fn update_run( &mut self, span_base: usize, @@ -231,6 +304,66 @@ impl Metadata { #[cfg(test)] mod tests { + fn segment(start: u64, end: u64, available: bool) -> Segment { + Segment { + path: format!("segment-{start}.mkv").into(), + start: Duration::from_secs(start), + end: Duration::from_secs(end), + complete: true, + available, + frame_start: start * 60, + frames_per_second: 60, + dropped_frames: 0, + } + } + + fn timeline(segments: Vec) -> Metadata { + Metadata { + session: Session { + id: 1, + game: "Game".into(), + started: UNIX_EPOCH, + length: Duration::from_secs(10), + active: false, + spans: Vec::new(), + bookmarks: Vec::new(), + }, + app_id: 1, + segments, + } + } + + #[test] + fn times_map_to_segment_offsets_and_gaps_have_no_footage() { + // 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s. + let metadata = timeline(vec![ + segment(0, 2, true), + segment(2, 4, true), + segment(6, 8, true), + segment(8, 10, false), + ]); + let ms = Duration::from_millis; + let at = metadata.locate(ms(2500)).unwrap(); + assert_eq!((at.index, at.offset), (1, ms(500))); + assert_eq!(metadata.locate(ms(2000)).unwrap().index, 1); + assert!(metadata.locate(ms(5000)).is_none()); + assert!(metadata.locate(ms(9000)).is_none()); + + let pieces = metadata.covering(ms(1500), ms(7000)); + let summary: Vec<_> = pieces + .iter() + .map(|piece| (piece.index, piece.from, piece.to, piece.session_start)) + .collect(); + assert_eq!( + summary, + [ + (0, ms(1500), ms(2000), ms(1500)), + (1, ms(0), ms(2000), ms(2000)), + (2, ms(0), ms(1000), ms(6000)), + ] + ); + } + use super::*; #[test] fn offsets_use_frames_without_accumulating_container_rounding() {