feat: map session times to segment files
Rust checks / windows-cross-check (push) Successful in 1m0s
Rust checks / linux (push) Failing after 11m37s

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:
2026-10-08 17:54:07 +02:00
parent 2faddbd684
commit 36c13449dd
+136 -3
View File
@@ -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() {