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
+170
View File
@@ -0,0 +1,170 @@
//! Budget selection is pure; the recorder performs deletions and persists spans.
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone)]
pub struct Candidate {
pub session: u64,
pub index: usize,
pub started: SystemTime,
pub active: bool,
pub start: Duration,
pub bytes: u64,
pub protected: bool,
pub complete: bool,
}
/// Touching endpoints do not overlap. Bookmark post-roll reaches ahead of the
/// current session length, protecting each segment as it arrives.
pub fn protected(
segment: &crate::store::Segment,
bookmarks: &[crate::library::Bookmark],
ranges: &[(Duration, Duration)],
) -> bool {
let overlaps = |(start, end)| start < segment.end && end > segment.start;
bookmarks
.iter()
.any(|b| b.protected && overlaps((b.at.saturating_sub(b.before), b.at + b.after)))
|| ranges.iter().copied().any(overlaps)
}
/// Incomplete files count towards usage but can never be removed.
pub fn select(candidates: &[Candidate], budget: u64) -> Vec<(u64, usize)> {
let mut usage: u64 = candidates
.iter()
.filter(|c| !c.protected)
.map(|c| c.bytes)
.sum();
let mut eligible: Vec<_> = candidates
.iter()
.filter(|c| !c.protected && c.complete)
.collect();
eligible.sort_by_key(|c| (c.active, c.started, c.start, c.index));
let mut selected = Vec::new();
for c in eligible {
if usage <= budget {
break;
}
selected.push((c.session, c.index));
usage = usage.saturating_sub(c.bytes);
}
selected
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(id: u64, active: bool, protected: bool, complete: bool) -> Candidate {
Candidate {
session: id,
index: 0,
started: SystemTime::UNIX_EPOCH + Duration::from_secs(id),
active,
start: Duration::ZERO,
bytes: 10,
protected,
complete,
}
}
#[test]
fn protection_covers_future_post_roll_and_export_but_not_touching_edges() {
use crate::{library::Bookmark, store::Segment};
let segment = Segment {
path: "s.mkv".into(),
start: Duration::from_secs(12),
end: Duration::from_secs(14),
complete: true,
available: true,
frame_start: 720,
frames_per_second: 60,
dropped_frames: 0,
audio_start_samples: Vec::new(),
};
let mut bookmark = Bookmark {
at: Duration::from_secs(10),
before: Duration::from_secs(2),
after: Duration::from_secs(4),
protected: true,
};
assert!(protected(&segment, &[bookmark], &[]));
bookmark.after = Duration::from_secs(2);
assert!(!protected(&segment, &[bookmark], &[]));
bookmark.after = Duration::from_secs(10);
bookmark.protected = false;
assert!(!protected(&segment, &[bookmark], &[]));
assert!(protected(
&segment,
&[],
&[(Duration::from_secs(13), Duration::from_secs(15))]
));
assert!(!protected(
&segment,
&[],
&[(Duration::ZERO, Duration::from_secs(12))]
));
}
#[test]
fn every_budget_and_protection_writer_combination_keeps_selection_safe() {
for flags in 0..64 {
let c: Vec<_> = (0..3)
.map(|i| {
candidate(
i + 1,
i == 0,
flags & (1 << (i * 2)) != 0,
flags & (1 << (i * 2 + 1)) != 0,
)
})
.collect();
for budget in 0..=30 {
let chosen = select(&c, budget);
for (id, _) in &chosen {
let item = &c[(*id - 1) as usize];
assert!(!item.protected && item.complete);
}
let usage: u64 = c
.iter()
.filter(|item| !item.protected)
.map(|item| item.bytes)
.sum();
let remaining = usage - chosen.len() as u64 * 10;
let writer: u64 = c
.iter()
.filter(|item| !item.protected && !item.complete)
.map(|item| item.bytes)
.sum();
assert!(remaining <= budget.max(writer));
if !chosen.is_empty() {
assert!(remaining + 10 > budget);
}
}
}
}
#[test]
fn completed_sessions_precede_older_active_footage() {
let c = [
candidate(1, true, false, true),
candidate(3, false, false, true),
candidate(2, false, false, true),
];
assert_eq!(select(&c, 10), [(2, 0), (3, 0)]);
}
#[test]
fn protected_bytes_are_excluded_and_writer_is_never_selected() {
let c = [
candidate(1, false, true, true),
candidate(2, true, false, false),
candidate(3, true, false, true),
];
assert_eq!(select(&c, 10), [(3, 0)]);
assert_eq!(select(&c, 0), [(3, 0)]);
assert!(select(&c, 20).is_empty());
}
#[test]
fn oldest_segments_first_and_stop_at_budget() {
let mut c = candidate(1, false, false, true);
c.index = 1;
c.start = Duration::from_secs(2);
assert_eq!(select(&[c, candidate(1, false, false, true)], 10), [(1, 0)]);
assert!(select(&[], 0).is_empty());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Saved MP4s and their source ranges. Clips are never part of automatic cleanup.
use crate::{
library::Clip,
store::{Store, atomic_json},
};
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf, time::Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedClip {
pub clip: Clip,
pub source_session: u64,
pub start: Duration,
pub end: Duration,
pub separate_audio: bool,
}
/// Pending and failed exports retain their source leases across app restarts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingExport {
pub session: u64,
pub start: Duration,
pub end: Duration,
pub separate: bool,
pub failed: bool,
}
impl Store {
pub fn pending_exports(&self) -> io::Result<Vec<PendingExport>> {
match fs::read(self.clips_directory().join("export-requests.json")) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub fn save_pending_exports(&self, exports: &[PendingExport]) -> io::Result<()> {
atomic_json(
&self.clips_directory().join("export-requests.json"),
&exports,
)
}
pub fn clips_directory(&self) -> PathBuf {
self.root.parent().unwrap_or(&self.root).join("clips")
}
pub fn clip_path(&self, id: u64) -> PathBuf {
self.clips_directory().join(format!("{id}.mp4"))
}
pub fn save_clip(&self, clip: &SavedClip) -> io::Result<()> {
atomic_json(
&self
.clips_directory()
.join(format!("{}.json", clip.clip.id)),
clip,
)
}
pub fn clips(&self) -> io::Result<Vec<SavedClip>> {
let entries = match fs::read_dir(self.clips_directory()) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e),
};
let mut clips = vec![];
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "json")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.parse::<u64>().is_ok())
{
let saved: SavedClip =
serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other)?;
if self.clip_path(saved.clip.id).is_file() {
clips.push(saved);
}
}
}
clips.sort_by_key(|s| std::cmp::Reverse(s.clip.created));
Ok(clips)
}
pub fn delete_clip(&self, id: u64) -> io::Result<()> {
fs::remove_file(self.clip_path(id))?;
fs::remove_file(self.clips_directory().join(format!("{id}.json")))
}
}
/// Launch an OS association without shell interpolation, off the UI thread.
pub fn open(path: PathBuf) -> io::Result<()> {
#[cfg(target_os = "linux")]
let status = std::process::Command::new("xdg-open").arg(path).status()?;
#[cfg(windows)]
let status = std::process::Command::new("explorer.exe")
.arg(path)
.status()?;
#[cfg(not(any(target_os = "linux", windows)))]
return Err(io::Error::other(
"Opening files is unsupported on this platform",
));
#[cfg(any(target_os = "linux", windows))]
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"File opener exited with {status}"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pending_and_failed_export_leases_round_trip_without_becoming_clips() {
let temp = tempfile::tempdir().unwrap();
let store = Store {
root: temp.path().join("sessions"),
};
let request = PendingExport {
session: 42,
start: Duration::from_secs(3),
end: Duration::from_secs(5),
separate: false,
failed: true,
};
store.save_pending_exports(&[request]).unwrap();
let requests = store.pending_exports().unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].failed);
assert_eq!(requests[0].session, 42);
assert!(store.clips().unwrap().is_empty());
store.save_pending_exports(&[]).unwrap();
assert!(store.pending_exports().unwrap().is_empty());
}
#[test]
fn clips_require_a_published_mp4_and_survive_session_recovery() {
let temp = tempfile::tempdir().unwrap();
let store = Store {
root: temp.path().join("sessions"),
};
let saved = SavedClip {
clip: Clip {
id: 1,
game: "Test".into(),
created: std::time::SystemTime::UNIX_EPOCH,
length: Duration::from_secs(2),
size_bytes: 4,
},
source_session: 42,
start: Duration::from_secs(3),
end: Duration::from_secs(5),
separate_audio: false,
};
store.save_clip(&saved).unwrap();
assert!(store.clips().unwrap().is_empty());
fs::write(store.clip_path(1), b"test").unwrap();
store.recover().unwrap();
assert_eq!(store.clips().unwrap()[0].source_session, 42);
let library = store.library(1).unwrap();
assert_eq!(library.storage.clip_bytes, 4);
assert_eq!(library.storage.unprotected_bytes, 0);
store.delete_clip(1).unwrap();
assert!(store.clips().unwrap().is_empty());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! OS global actions; capture and UI work stay on their own threads.
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Instant,
};
#[derive(Debug, Clone, Copy)]
pub enum Action {
Clip,
Bookmark,
}
pub struct Hotkeys {
stop: Arc<AtomicBool>,
}
impl Drop for Hotkeys {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
pub fn start(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: impl Fn(String) + Send + Sync + 'static,
) -> Hotkeys {
let stop = Arc::new(AtomicBool::new(false));
let flag = stop.clone();
thread::spawn(move || {
// Give a previous registration time to release its OS grabs on rebind.
thread::sleep(std::time::Duration::from_millis(150));
if flag.load(Ordering::Relaxed) {
return;
}
if !(1..=12).contains(&clip) || !(1..=12).contains(&bookmark) || clip == bookmark {
status("Invalid global bindings; use the Clip/Bookmark buttons".into());
return;
}
let result = platform(clip, bookmark, action, &status, flag.clone());
if let Err(e) = result
&& !flag.load(Ordering::Relaxed)
{
status(format!(
"Global hotkeys unavailable: {e}. Use Clip/Bookmark buttons."
));
}
});
Hotkeys { stop }
}
#[cfg(windows)]
mod windows;
#[cfg(windows)]
use windows::platform;
#[cfg(target_os = "linux")]
fn platform(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: &(impl Fn(String) + Send + Sync + 'static),
stop: Arc<AtomicBool>,
) -> Result<(), String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
async_io::block_on(wayland(clip, bookmark, action, status, &stop))
} else {
use x11rb::{
connection::Connection,
protocol::{
Event,
xkb::{self, ConnectionExt as _},
xproto::{ConnectionExt, GrabMode, ModMask},
},
};
let (connection, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
connection
.xkb_use_extension(1, 0)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
connection
.xkb_per_client_flags(
xkb::ID::USE_CORE_KBD.into(),
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
0u32.into(),
0u32.into(),
0u32.into(),
)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
let root = connection.setup().roots[screen].root;
let setup = connection.setup();
let keys = connection
.get_keyboard_mapping(setup.min_keycode, setup.max_keycode - setup.min_keycode + 1)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
let lookup = |f: u8| {
keys.keysyms
.chunks(keys.keysyms_per_keycode as usize)
.position(|row| row.contains(&(0xffbd + u32::from(f))))
.map(|i| setup.min_keycode + i as u8)
.ok_or("Function key missing in X11 map")
};
let codes = [lookup(clip)?, lookup(bookmark)?];
// Ignore the common Caps/Num lock masks when grabbing.
for code in codes {
for locks in [0, 2, 16, 18] {
connection
.grab_key(
false,
root,
ModMask::CONTROL | ModMask::SHIFT | ModMask::from(locks as u16),
code,
GrabMode::ASYNC,
GrabMode::ASYNC,
)
.map_err(|e| e.to_string())?
.check()
.map_err(|e| e.to_string())?;
}
}
connection.flush().map_err(|e| e.to_string())?;
status(format!(
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
));
let mut held = [false; 2];
while !stop.load(Ordering::Relaxed) {
match connection.poll_for_event().map_err(|e| e.to_string())? {
Some(Event::KeyPress(key)) => {
if let Some(index) = codes.iter().position(|code| *code == key.detail)
&& !held[index]
{
held[index] = true;
action(
if index == 0 {
Action::Clip
} else {
Action::Bookmark
},
Instant::now(),
);
}
}
Some(Event::KeyRelease(key)) => {
if let Some(index) = codes.iter().position(|code| *code == key.detail) {
held[index] = false;
}
}
_ => {}
}
thread::sleep(std::time::Duration::from_millis(10));
}
Ok(())
}
}
#[cfg(not(any(target_os = "linux", windows)))]
fn platform(
_: u8,
_: u8,
_: impl Fn(Action, Instant) + Send + Sync + 'static,
_: &(impl Fn(String) + Send + Sync + 'static),
_: Arc<AtomicBool>,
) -> Result<(), String> {
Err("Unsupported OS".into())
}
#[cfg(target_os = "linux")]
async fn wayland(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant),
status: &impl Fn(String),
stop: &AtomicBool,
) -> Result<(), String> {
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
use futures_util::StreamExt;
let portal = GlobalShortcuts::new().await.map_err(|e| e.to_string())?;
let session = portal.create_session().await.map_err(|e| e.to_string())?;
let mut activated = portal
.receive_activated()
.await
.map_err(|e| e.to_string())?;
// ashpd exposes the session handle through Serialize, not a public accessor.
let session_handle = serde_json::to_value(&session).map_err(|e| e.to_string())?;
let clip_trigger = format!("CTRL+SHIFT+F{clip}");
let bookmark_trigger = format!("CTRL+SHIFT+F{bookmark}");
let binding = async {
portal
.bind_shortcuts(
&session,
&[
NewShortcut::new("clip", "Save recent Clipbin footage")
.preferred_trigger(Some(clip_trigger.as_str())),
NewShortcut::new("bookmark", "Protect a Clipbin bookmark")
.preferred_trigger(Some(bookmark_trigger.as_str())),
],
None,
)
.await
.map_err(|e| e.to_string())?
.response()
.map_err(|e| e.to_string())
};
let Some(bindings) = until_stopped(binding, stop).await else {
return session.close().await.map_err(|e| e.to_string());
};
let bindings = match bindings {
Ok(b) => b,
Err(e) => {
let _ = session.close().await;
return Err(e);
}
};
status(
bindings
.shortcuts()
.iter()
.map(|b| format!("{}: {}", b.id(), b.trigger_description()))
.collect::<Vec<_>>()
.join(" · "),
);
while let Some(Some(signal)) = until_stopped(activated.next(), stop).await {
if session_handle.as_str() != Some(signal.session_handle().as_str()) {
continue;
}
let kind = match signal.shortcut_id() {
"clip" => Action::Clip,
"bookmark" => Action::Bookmark,
_ => continue,
};
action(kind, Instant::now());
}
session.close().await.map_err(|e| e.to_string())
}
#[cfg(target_os = "linux")]
async fn until_stopped<F: std::future::Future>(future: F, stop: &AtomicBool) -> Option<F::Output> {
futures_lite::future::race(async { Some(future.await) }, async {
while !stop.load(Ordering::Relaxed) {
async_io::Timer::after(std::time::Duration::from_millis(100)).await;
}
None
})
.await
}
@@ -0,0 +1,53 @@
//! RegisterHotKey and its thread-local message queue are confined to this bridge.
#![allow(unsafe_code)]
use super::*;
use windows_sys::Win32::UI::{
Input::KeyboardAndMouse::{
MOD_CONTROL, MOD_NOREPEAT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey, VK_F1,
},
WindowsAndMessaging::{MSG, PM_REMOVE, PeekMessageW, WM_HOTKEY},
};
pub(super) fn platform(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: &(impl Fn(String) + Send + Sync + 'static),
stop: Arc<AtomicBool>,
) -> Result<(), String> {
// No window handle: registration and message consumption belong to this thread.
unsafe {
for (id, key) in [(1, clip), (2, bookmark)] {
if RegisterHotKey(
std::ptr::null_mut(),
id,
MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT,
u32::from(VK_F1) + u32::from(key) - 1,
) == 0
{
let error = std::io::Error::last_os_error();
UnregisterHotKey(std::ptr::null_mut(), 1);
return Err(error.to_string());
}
}
status(format!(
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
));
let mut message: MSG = std::mem::zeroed();
while !stop.load(Ordering::Relaxed) {
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
if message.message == WM_HOTKEY {
match message.wParam {
1 => action(Action::Clip, Instant::now()),
2 => action(Action::Bookmark, Instant::now()),
_ => {}
}
}
}
thread::sleep(std::time::Duration::from_millis(10));
}
UnregisterHotKey(std::ptr::null_mut(), 1);
UnregisterHotKey(std::ptr::null_mut(), 2);
}
Ok(())
}
+5
View File
@@ -15,3 +15,8 @@ pub mod store;
mod windows_process;
pub mod segments;
pub mod cleanup;
pub mod clips;
pub mod hotkeys;
+21 -6
View File
@@ -115,13 +115,19 @@ pub fn export_range(
keyframe_interval: Duration,
session_length: Duration,
) -> (Duration, Duration) {
let interval = keyframe_interval.as_millis().max(1);
let floor =
|time: Duration| Duration::from_millis((time.as_millis() / interval * interval) as u64);
let ceil = |time: Duration| {
Duration::from_millis((time.as_millis().div_ceil(interval) * interval) as u64)
let interval = keyframe_interval.as_nanos().max(1);
let duration = |nanos: u128| {
Duration::new(
(nanos / 1_000_000_000) as u64,
(nanos % 1_000_000_000) as u32,
)
};
(floor(start), ceil(end).min(session_length))
let floor = |time: Duration| duration(time.as_nanos() / interval * interval);
let ceil = |time: Duration| duration(time.as_nanos().div_ceil(interval) * interval);
(
floor(start).min(session_length),
ceil(end).min(session_length),
)
}
/// `1:02:03` or `2:03`.
@@ -348,6 +354,15 @@ mod tests {
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
let ms = Duration::from_millis;
let second = Duration::from_secs(1);
assert_eq!(
export_range(
Duration::from_nanos(1_000_000_001),
Duration::from_nanos(2_000_000_001),
second,
ms(60_000)
),
(second, Duration::from_secs(3))
);
assert_eq!(
export_range(ms(10_400), ms(20_100), second, ms(60_000)),
(ms(10_000), ms(21_000))
+6
View File
@@ -33,6 +33,9 @@ pub struct Settings {
pub microphone_enabled: bool,
pub start_on_login: bool,
pub clip_duration: Duration,
pub export_separate_audio: bool,
pub clip_hotkey: u8,
pub bookmark_hotkey: u8,
pub bookmark_before: Duration,
pub bookmark_after: Duration,
/// Protected bookmarks and exported clips are excluded from this limit.
@@ -50,6 +53,9 @@ impl Default for Settings {
microphone_enabled: false,
start_on_login: true,
clip_duration: Duration::from_secs(120),
export_separate_audio: false,
clip_hotkey: 9,
bookmark_hotkey: 10,
bookmark_before: Duration::from_secs(120),
bookmark_after: Duration::from_secs(30),
unprotected_session_limit_bytes: 50_000_000_000,
+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"),