feat: map session times to segment files
Metadata::locate finds the segment and in-file offset for a session time (none inside gaps or empty tails), Metadata::covering lists the segment pieces in a range for export, and Store::metadata loads one session with resolved paths. Shared groundwork for playback and clip export.
This commit is contained in:
@@ -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<Segment>,
|
||||
}
|
||||
|
||||
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<Metadata> {
|
||||
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<Library> {
|
||||
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<Item = (usize, &Segment)> {
|
||||
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<Located<'_>> {
|
||||
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<Piece<'_>> {
|
||||
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<Segment>) -> 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() {
|
||||
|
||||
Reference in New Issue
Block a user