632 lines
22 KiB
Rust
632 lines
22 KiB
Rust
//! Atomic session metadata; segment paths are relative to their session directory.
|
|
use crate::{
|
|
Library, Settings,
|
|
library::{Session, Span, SpanKind},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
fs, io,
|
|
path::{Path, PathBuf},
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Segment {
|
|
pub path: PathBuf,
|
|
pub start: Duration,
|
|
pub end: Duration,
|
|
pub complete: bool,
|
|
/// False for a header-only file left by a split racing with stop.
|
|
#[serde(default = "available_default")]
|
|
pub available: bool,
|
|
#[serde(default)]
|
|
pub frame_start: u64,
|
|
#[serde(default = "default_fps")]
|
|
pub frames_per_second: u32,
|
|
/// 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 {
|
|
true
|
|
}
|
|
fn default_fps() -> u32 {
|
|
60
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Metadata {
|
|
pub session: Session,
|
|
pub app_id: u32,
|
|
pub segments: Vec<Segment>,
|
|
}
|
|
|
|
const METADATA_FILE: &str = "session.json";
|
|
|
|
#[derive(Clone)]
|
|
pub struct Store {
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
pub fn config_path() -> io::Result<PathBuf> {
|
|
Ok(dirs::config_dir()
|
|
.ok_or_else(|| io::Error::other("No configuration directory"))?
|
|
.join("clipbin/settings.json"))
|
|
}
|
|
|
|
pub fn load_settings() -> io::Result<Settings> {
|
|
match fs::read(config_path()?) {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Settings::default()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
pub fn save_settings(settings: &Settings) -> io::Result<()> {
|
|
atomic_json(&config_path()?, settings)
|
|
}
|
|
|
|
/// Same-directory replacement keeps readers and crash recovery away from partial JSON.
|
|
pub fn atomic_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
|
|
fs::create_dir_all(
|
|
path.parent()
|
|
.ok_or_else(|| io::Error::other("No parent directory"))?,
|
|
)?;
|
|
let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
|
|
serde_json::to_writer_pretty(temporary.as_file_mut(), value).map_err(io::Error::other)?;
|
|
temporary.as_file().sync_all()?;
|
|
temporary.persist(path).map_err(|e| e.error)?;
|
|
#[cfg(unix)]
|
|
fs::File::open(path.parent().unwrap())?.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Frame offsets never depend on container timestamps (each segment starts at zero).
|
|
pub fn frame_offset(frames: u64, fps: u32) -> Duration {
|
|
let fps = u64::from(fps.max(1));
|
|
Duration::new(frames / fps, ((frames % fps) * 1_000_000_000 / fps) as u32)
|
|
}
|
|
|
|
/// The keyframe triggering `file_changed` is counted already, but is the new
|
|
/// segment's first frame. Zero identifies the initial `OutputStarted` segment.
|
|
pub fn segment_offset(total_frames: u64, fps: u32) -> Duration {
|
|
frame_offset(total_frames.saturating_sub(1), fps)
|
|
}
|
|
|
|
impl Store {
|
|
pub fn discover() -> io::Result<Self> {
|
|
let root = dirs::data_local_dir()
|
|
.ok_or_else(|| io::Error::other("No data directory"))?
|
|
.join(if cfg!(windows) { "Clipbin" } else { "clipbin" });
|
|
Ok(Self {
|
|
root: root.join("sessions"),
|
|
})
|
|
}
|
|
pub fn directory(&self, id: u64) -> PathBuf {
|
|
self.root.join(id.to_string())
|
|
}
|
|
pub fn save(&self, metadata: &Metadata) -> io::Result<()> {
|
|
atomic_json(
|
|
&self.directory(metadata.session.id).join(METADATA_FILE),
|
|
metadata,
|
|
)
|
|
}
|
|
pub fn create(&self, app_id: u32, game: String) -> io::Result<Metadata> {
|
|
let started = SystemTime::now();
|
|
let mut id = started
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64;
|
|
while self.directory(id).exists() {
|
|
id += 1;
|
|
}
|
|
let metadata = Metadata {
|
|
session: Session {
|
|
id,
|
|
game,
|
|
started,
|
|
length: Duration::ZERO,
|
|
active: true,
|
|
spans: vec![],
|
|
bookmarks: vec![],
|
|
},
|
|
app_id,
|
|
segments: vec![],
|
|
};
|
|
self.save(&metadata)?;
|
|
Ok(metadata)
|
|
}
|
|
pub fn read(&self) -> io::Result<Vec<Metadata>> {
|
|
let entries = match fs::read_dir(&self.root) {
|
|
Ok(e) => e,
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
|
Err(e) => return Err(e),
|
|
};
|
|
let mut sessions = vec![];
|
|
for entry in entries {
|
|
let path = entry?.path().join(METADATA_FILE);
|
|
if path.exists() {
|
|
let bytes = fs::read(&path)?;
|
|
sessions.push(
|
|
serde_json::from_slice(&bytes)
|
|
.map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?,
|
|
);
|
|
}
|
|
}
|
|
Ok(sessions)
|
|
}
|
|
/// Active metadata cannot belong to a live recorder during initial startup.
|
|
pub fn recover(&self) -> io::Result<()> {
|
|
for mut metadata in self.read()? {
|
|
if metadata.session.active {
|
|
metadata.session.active = false;
|
|
for segment in &mut metadata.segments {
|
|
segment.complete = true;
|
|
}
|
|
self.save(&metadata)?;
|
|
}
|
|
}
|
|
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> {
|
|
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 crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) {
|
|
library.storage.protected_bytes += bytes;
|
|
} else {
|
|
library.storage.unprotected_bytes += bytes;
|
|
}
|
|
}
|
|
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));
|
|
Ok(library)
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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,
|
|
footage_start: Option<Duration>,
|
|
footage_end: Duration,
|
|
elapsed: Duration,
|
|
) {
|
|
self.session.spans.truncate(span_base);
|
|
if let Some(start) = footage_start {
|
|
let previous = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
|
|
if start > previous {
|
|
self.session.spans.push(Span {
|
|
start: previous,
|
|
end: start,
|
|
kind: SpanKind::Missing,
|
|
});
|
|
}
|
|
if footage_end > start {
|
|
self.session.spans.push(Span {
|
|
start,
|
|
end: footage_end,
|
|
kind: SpanKind::Footage,
|
|
});
|
|
}
|
|
} else {
|
|
let start = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
|
|
if elapsed > start {
|
|
self.session.spans.push(Span {
|
|
start,
|
|
end: elapsed,
|
|
kind: SpanKind::Missing,
|
|
});
|
|
}
|
|
}
|
|
self.session.length = elapsed.max(footage_end);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
audio_start_samples: Vec::new(),
|
|
}
|
|
}
|
|
|
|
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 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.
|
|
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() {
|
|
assert_eq!(segment_offset(0, 60), Duration::ZERO);
|
|
assert_eq!(segment_offset(121, 60), Duration::from_secs(2));
|
|
assert_eq!(segment_offset(241, 60), Duration::from_secs(4));
|
|
assert_eq!(segment_offset(361, 60), Duration::from_secs(6));
|
|
assert_eq!(frame_offset(120, 60), Duration::from_secs(2));
|
|
assert_eq!(frame_offset(121, 60), Duration::from_nanos(2_016_666_666));
|
|
assert_eq!(frame_offset(216_000, 60), Duration::from_secs(3600));
|
|
}
|
|
#[test]
|
|
fn recovery_preserves_footage_and_a_wall_clock_gap() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let store = Store {
|
|
root: directory.path().into(),
|
|
};
|
|
let mut metadata = store.create(480, "Game".into()).unwrap();
|
|
metadata.update_run(
|
|
0,
|
|
Some(Duration::from_secs(1)),
|
|
Duration::from_secs(5),
|
|
Duration::from_secs(5),
|
|
);
|
|
let base = metadata.session.spans.len();
|
|
metadata.update_run(base, None, Duration::ZERO, Duration::from_secs(8));
|
|
assert_eq!(metadata.session.missing_time(), Duration::from_secs(4));
|
|
metadata.update_run(
|
|
base,
|
|
Some(Duration::from_secs(10)),
|
|
Duration::from_secs(12),
|
|
Duration::from_secs(12),
|
|
);
|
|
assert_eq!(metadata.session.missing_time(), Duration::from_secs(6));
|
|
assert_eq!(
|
|
metadata.session.spans.last().unwrap().kind,
|
|
SpanKind::Footage
|
|
);
|
|
}
|
|
#[test]
|
|
fn atomic_writes_leave_readers_a_complete_document() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let path = directory.path().join("settings.json");
|
|
atomic_json(&path, &Settings::default()).unwrap();
|
|
std::thread::scope(|scope| {
|
|
let reader = scope.spawn(|| {
|
|
for _ in 0..100 {
|
|
let bytes = fs::read(&path).unwrap();
|
|
serde_json::from_slice::<Settings>(&bytes).unwrap();
|
|
}
|
|
});
|
|
for rate in [30, 60, 120] {
|
|
atomic_json(
|
|
&path,
|
|
&Settings {
|
|
frames_per_second: rate,
|
|
..Settings::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
reader.join().unwrap();
|
|
});
|
|
}
|
|
#[test]
|
|
fn round_trip_atomic_replace_and_crash_recovery() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"clipbin-store-{}-{}",
|
|
std::process::id(),
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let store = Store { root };
|
|
let mut metadata = store.create(480, "Test game".into()).unwrap();
|
|
metadata.session.length = Duration::from_secs(7);
|
|
metadata.segments.push(Segment {
|
|
path: PathBuf::from("segment.mkv"),
|
|
start: Duration::ZERO,
|
|
end: Duration::from_secs(7),
|
|
complete: false,
|
|
available: true,
|
|
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"),
|
|
[0u8; 13],
|
|
)
|
|
.unwrap();
|
|
store.save(&metadata).unwrap();
|
|
assert!(
|
|
!store
|
|
.directory(metadata.session.id)
|
|
.join("session.json.tmp")
|
|
.exists()
|
|
);
|
|
assert_eq!(store.read().unwrap()[0].session, metadata.session);
|
|
fs::write(
|
|
store.directory(metadata.session.id).join("interrupted.tmp"),
|
|
b"{partial",
|
|
)
|
|
.unwrap();
|
|
store.recover().unwrap();
|
|
let library = store.library(100).unwrap();
|
|
assert!(!library.sessions[0].active);
|
|
assert_eq!(library.sessions[0].length, Duration::from_secs(7));
|
|
assert_eq!(library.storage.unprotected_bytes, 13);
|
|
assert!(store.read().unwrap()[0].segments[0].complete);
|
|
fs::remove_dir_all(store.root).unwrap();
|
|
}
|
|
}
|