feat: export clips, protect bookmarks and clean session storage

This commit is contained in:
2026-10-08 18:56:51 +02:00
parent f6cdef5dd2
commit 7ac7e66ff1
28 changed files with 2286 additions and 35 deletions
+155 -4
View File
@@ -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"),