feat: export clips, protect bookmarks and clean session storage
This commit is contained in:
@@ -26,6 +26,9 @@ pub struct Segment {
|
||||
/// Cumulative dropped frames in this run when the segment completed.
|
||||
#[serde(default)]
|
||||
pub dropped_frames: u64,
|
||||
/// Exact cumulative AAC samples before this file, independently per audio stream.
|
||||
#[serde(default)]
|
||||
pub audio_start_samples: Vec<u64>,
|
||||
}
|
||||
|
||||
fn available_default() -> bool {
|
||||
@@ -181,15 +184,24 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn library(&self, budget: u64) -> io::Result<Library> {
|
||||
self.library_with_protection(budget, &[])
|
||||
}
|
||||
pub fn library_with_protection(
|
||||
&self,
|
||||
budget: u64,
|
||||
ranges: &[(u64, Duration, Duration)],
|
||||
) -> io::Result<Library> {
|
||||
let mut library = Library::empty(budget);
|
||||
for metadata in self.read()? {
|
||||
let leased: Vec<_> = ranges
|
||||
.iter()
|
||||
.filter(|(id, _, _)| *id == metadata.session.id)
|
||||
.map(|(_, start, end)| (*start, *end))
|
||||
.collect();
|
||||
for segment in &metadata.segments {
|
||||
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
|
||||
.map_or(0, |f| f.len());
|
||||
if metadata.session.bookmarks.iter().any(|b| {
|
||||
let (start, end) = b.range(metadata.session.length);
|
||||
b.protected && start < segment.end && end > segment.start
|
||||
}) {
|
||||
if crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) {
|
||||
library.storage.protected_bytes += bytes;
|
||||
} else {
|
||||
library.storage.unprotected_bytes += bytes;
|
||||
@@ -197,6 +209,10 @@ impl Store {
|
||||
}
|
||||
library.sessions.push(metadata.session);
|
||||
}
|
||||
for saved in self.clips()? {
|
||||
library.storage.clip_bytes += saved.clip.size_bytes;
|
||||
library.clips.push(saved.clip);
|
||||
}
|
||||
library
|
||||
.sessions
|
||||
.sort_by_key(|s| std::cmp::Reverse(s.started));
|
||||
@@ -264,6 +280,82 @@ impl Metadata {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keyframes are anchored to each capture run, which may start after a gap.
|
||||
pub fn export_range(&self, start: Duration, end: Duration) -> (Duration, Duration) {
|
||||
let anchor = |time| {
|
||||
self.segments
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.available && s.start <= time)
|
||||
.map_or(time, |segment| {
|
||||
segment.start.saturating_sub(frame_offset(
|
||||
segment.frame_start,
|
||||
segment.frames_per_second,
|
||||
))
|
||||
})
|
||||
};
|
||||
let a = anchor(start);
|
||||
let b = anchor(end.saturating_sub(Duration::from_nanos(1)));
|
||||
let (from, _) = crate::library::export_range(
|
||||
start.saturating_sub(a),
|
||||
start.saturating_sub(a),
|
||||
Duration::from_secs(1),
|
||||
self.session.length.max(end + Duration::from_secs(1)),
|
||||
);
|
||||
let (_, to) = crate::library::export_range(
|
||||
end.saturating_sub(b),
|
||||
end.saturating_sub(b),
|
||||
Duration::from_secs(1),
|
||||
self.session.length.max(end + Duration::from_secs(1)),
|
||||
);
|
||||
let start = (a + from).min(self.session.length);
|
||||
let end = if self.session.active {
|
||||
b + to
|
||||
} else {
|
||||
(b + to).min(self.session.length)
|
||||
};
|
||||
let pieces = self.covering(start, end);
|
||||
let start = pieces.first().map_or(start, |p| p.session_start);
|
||||
let end = if self.session.active {
|
||||
end
|
||||
} else {
|
||||
pieces.last().map_or(end, |p| p.segment.start + p.to)
|
||||
};
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Split existing spans so deleted footage remains visible on the timeline.
|
||||
pub fn removed(&mut self, index: usize) {
|
||||
let segment = &mut self.segments[index];
|
||||
segment.available = false;
|
||||
let (start, end) = (segment.start, segment.end);
|
||||
let mut spans = Vec::new();
|
||||
for span in &self.session.spans {
|
||||
if span.kind != SpanKind::Footage || span.end <= start || span.start >= end {
|
||||
spans.push(*span);
|
||||
continue;
|
||||
}
|
||||
if span.start < start {
|
||||
spans.push(Span {
|
||||
end: start,
|
||||
..*span
|
||||
});
|
||||
}
|
||||
spans.push(Span {
|
||||
start: span.start.max(start),
|
||||
end: span.end.min(end),
|
||||
kind: SpanKind::Removed,
|
||||
});
|
||||
if span.end > end {
|
||||
spans.push(Span {
|
||||
start: end,
|
||||
..*span
|
||||
});
|
||||
}
|
||||
}
|
||||
self.session.spans = spans;
|
||||
}
|
||||
|
||||
pub fn update_run(
|
||||
&mut self,
|
||||
span_base: usize,
|
||||
@@ -314,6 +406,7 @@ mod tests {
|
||||
frame_start: start * 60,
|
||||
frames_per_second: 60,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +426,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_metadata_defaults_audio_clocks_without_losing_bookmarks() {
|
||||
let m = timeline(vec![segment(0, 2, true)]);
|
||||
let mut json = serde_json::to_value(m).unwrap();
|
||||
json["segments"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("audio_start_samples");
|
||||
let restored: Metadata = serde_json::from_value(json).unwrap();
|
||||
assert!(restored.segments[0].audio_start_samples.is_empty());
|
||||
assert!(restored.segments[0].available);
|
||||
}
|
||||
#[test]
|
||||
fn export_keyframes_follow_run_anchor_and_outer_gaps_are_trimmed() {
|
||||
let mut metadata = timeline(vec![segment(2, 4, true), segment(4, 6, true)]);
|
||||
metadata.segments[0].frame_start = 0;
|
||||
metadata.segments[1].frame_start = 120;
|
||||
assert_eq!(
|
||||
metadata.export_range(Duration::from_millis(2400), Duration::from_millis(5100)),
|
||||
(Duration::from_secs(2), Duration::from_secs(6))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.export_range(Duration::ZERO, Duration::from_secs(10)),
|
||||
(Duration::from_secs(2), Duration::from_secs(6))
|
||||
);
|
||||
metadata.session.active = true;
|
||||
assert_eq!(
|
||||
metadata
|
||||
.export_range(Duration::ZERO, Duration::from_secs(9))
|
||||
.1,
|
||||
Duration::from_secs(9)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn removal_splits_footage_preserving_missing_spans_and_is_idempotent() {
|
||||
let mut metadata = timeline(vec![segment(2, 4, true)]);
|
||||
metadata.session.spans = vec![
|
||||
Span {
|
||||
start: Duration::ZERO,
|
||||
end: Duration::from_secs(6),
|
||||
kind: SpanKind::Footage,
|
||||
},
|
||||
Span {
|
||||
start: Duration::from_secs(6),
|
||||
end: Duration::from_secs(8),
|
||||
kind: SpanKind::Missing,
|
||||
},
|
||||
];
|
||||
metadata.removed(0);
|
||||
assert_eq!(metadata.session.removed_time(), Duration::from_secs(2));
|
||||
assert_eq!(metadata.session.missing_time(), Duration::from_secs(2));
|
||||
assert!(!metadata.segments[0].available);
|
||||
let spans = metadata.session.spans.clone();
|
||||
metadata.removed(0);
|
||||
assert_eq!(metadata.session.spans, spans);
|
||||
assert!(metadata.locate(Duration::from_secs(3)).is_none());
|
||||
}
|
||||
#[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.
|
||||
@@ -450,6 +600,7 @@ mod tests {
|
||||
frame_start: 0,
|
||||
frames_per_second: 60,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: Vec::new(),
|
||||
});
|
||||
fs::write(
|
||||
store.directory(metadata.session.id).join("segment.mkv"),
|
||||
|
||||
Reference in New Issue
Block a user