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},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Segment {
|
pub struct Segment {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub start: Duration,
|
pub start: Duration,
|
||||||
@@ -42,6 +42,8 @@ pub struct Metadata {
|
|||||||
pub segments: Vec<Segment>,
|
pub segments: Vec<Segment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METADATA_FILE: &str = "session.json";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
@@ -105,7 +107,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
pub fn save(&self, metadata: &Metadata) -> io::Result<()> {
|
pub fn save(&self, metadata: &Metadata) -> io::Result<()> {
|
||||||
atomic_json(
|
atomic_json(
|
||||||
&self.directory(metadata.session.id).join("session.json"),
|
&self.directory(metadata.session.id).join(METADATA_FILE),
|
||||||
metadata,
|
metadata,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ impl Store {
|
|||||||
};
|
};
|
||||||
let mut sessions = vec![];
|
let mut sessions = vec![];
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let path = entry?.path().join("session.json");
|
let path = entry?.path().join(METADATA_FILE);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let bytes = fs::read(&path)?;
|
let bytes = fs::read(&path)?;
|
||||||
sessions.push(
|
sessions.push(
|
||||||
@@ -166,6 +168,18 @@ impl Store {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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> {
|
pub fn library(&self, budget: u64) -> io::Result<Library> {
|
||||||
let mut library = Library::empty(budget);
|
let mut library = Library::empty(budget);
|
||||||
for metadata in self.read()? {
|
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 {
|
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(
|
pub fn update_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
span_base: usize,
|
span_base: usize,
|
||||||
@@ -231,6 +304,66 @@ impl Metadata {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
fn offsets_use_frames_without_accumulating_container_rounding() {
|
fn offsets_use_frames_without_accumulating_container_rounding() {
|
||||||
|
|||||||
Reference in New Issue
Block a user