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
Generated
+6
View File
@@ -863,13 +863,18 @@ dependencies = [
name = "clipbin-core"
version = "0.1.0"
dependencies = [
"ashpd 0.12.3",
"async-io",
"dirs 5.0.1",
"futures-lite 2.6.1",
"futures-util",
"rustix 1.1.5",
"serde",
"serde_json",
"tempfile",
"windows-sys 0.61.2",
"winreg 0.55.0",
"x11rb",
]
[[package]]
@@ -877,6 +882,7 @@ name = "clipbin-desktop"
version = "0.1.0"
dependencies = [
"clipbin-core",
"clipbin-media",
"clipbin-obs",
"gpui",
]
+4 -1
View File
@@ -10,7 +10,7 @@ The recording foundation is being validated (see [docs/validation.md](docs/valid
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window keeps the current session recording until the game exits, then exits the app. Ctrl+Q or Quit stops the output cleanly.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback, export, global hotkeys, storage cleanup, autostart and a tray are not implemented yet. After closing the only window, reopening it without a tray is a follow-up.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Clip export, bookmark protection and storage cleanup are implemented. Playback, autostart and a tray are separate work. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. After closing the only window, reopening it without a tray is a follow-up.
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
@@ -19,6 +19,7 @@ Windows is a first-class target. Every change must keep the Windows code paths b
```text
crates/
clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and OBS.
clipbin-media/ FFmpeg stream-copy exports and shared media reading.
clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs.
clipbin-probe/ Validation probe for capture, encoders, segments and status.
clipbin-desktop/ GPUI application and initial session-library view.
@@ -87,6 +88,8 @@ cargo test --locked --workspace
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
```
Clips/bookmarks/cleanup validation: `scripts/validate/linux-clips.sh`. Native X11 hotkey validation: `scripts/validate/linux-hotkeys.sh`. Both run in the headless rig. Real Hyprland portal hotkeys and Windows runtime behaviour still need validation.
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times.
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
+6 -1
View File
@@ -18,8 +18,13 @@ serde_json = "1"
tempfile = "3"
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] }
async-io = "2"
futures-lite = "2"
futures-util = "0.3"
x11rb = { version = "0.13", features = ["xkb"] }
rustix = { version = "1", features = ["process", "event"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.55"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
+27
View File
@@ -0,0 +1,27 @@
//! Validation helper: exits after both native global actions arrive.
use std::{sync::mpsc, time::Duration};
fn main() {
let (sender, receiver) = mpsc::channel();
let _hotkeys = clipbin_core::hotkeys::start(
9,
10,
move |action, _| {
sender.send(action).unwrap();
},
|status| println!("hotkeys={status}"),
);
let first = receiver
.recv_timeout(Duration::from_secs(20))
.expect("first action");
let second = receiver
.recv_timeout(Duration::from_secs(20))
.expect("second action");
assert!(matches!(
(first, second),
(
clipbin_core::hotkeys::Action::Clip,
clipbin_core::hotkeys::Action::Bookmark
)
));
println!("PASS native clip and bookmark hotkeys");
}
+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"),
+1
View File
@@ -10,6 +10,7 @@ publish.workspace = true
[dependencies]
clipbin-core.workspace = true
clipbin-media.workspace = true
clipbin-obs.workspace = true
gpui.workspace = true
+50 -1
View File
@@ -17,7 +17,7 @@ use gpui::{
};
use crate::{
components::{icon, meter},
components::{ButtonStyle, button, icon, meter},
screens,
text_field::{Committed, TextField},
theme,
@@ -69,6 +69,9 @@ pub struct ClipbinApp {
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
pub fields: SettingsFields,
pub service: crate::recorder_service::Client,
pub hotkeys: String,
pub failed_exports: usize,
last_service_notice: Option<String>,
}
/// The typed settings and the fields that edit them.
@@ -239,6 +242,9 @@ impl ClipbinApp {
timeline_bounds: Rc::default(),
fields,
service,
hotkeys: "Registering global hotkeys…".into(),
failed_exports: 0,
last_service_notice: None,
};
match std::env::var("CLIPBIN_ROUTE").as_deref() {
Ok("clips") => app.route = Route::Clips,
@@ -262,6 +268,25 @@ impl ClipbinApp {
if entity
.update(cx, |app, cx| {
let snapshot = app.service.snapshot();
if app.last_service_notice != snapshot.notice {
app.notice = snapshot.notice.clone().map(Into::into);
app.last_service_notice = snapshot.notice.clone();
cx.notify();
}
if app.failed_exports != snapshot.failed_exports {
app.failed_exports = snapshot.failed_exports;
cx.notify();
}
if app.hotkeys != snapshot.hotkeys {
app.hotkeys = snapshot.hotkeys.clone();
cx.notify();
}
app.service.protect(match app.route {
Route::Session(id) => {
Some((id, app.editor.selection.0, app.editor.selection.1))
}
_ => None,
});
if app.recorder.status != snapshot.status
|| app.recorder.game != snapshot.game
|| app.library != snapshot.library
@@ -438,6 +463,30 @@ impl ClipbinApp {
)),
)
.child(div().flex_1())
.child(
button(
"save-recent",
ButtonStyle::Secondary,
Some("scissors"),
Some("Clip"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.clip();
cx.notify();
})),
)
.child(
button(
"add-bookmark",
ButtonStyle::Secondary,
Some("bookmark"),
Some("Bookmark"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.bookmark();
cx.notify();
})),
)
.child(self.recorder_card())
.child(self.storage_card())
}
+209
View File
@@ -0,0 +1,209 @@
//! A bounded queue and one remux thread; source leases live on the recorder.
use clipbin_core::{
clips::{PendingExport, SavedClip},
library::Clip,
store::Store,
};
use std::{
sync::mpsc::{self, Receiver},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[derive(Clone)]
pub struct Request {
pub session: u64,
pub start: Duration,
pub end: Duration,
pub separate: bool,
pub running: bool,
pub failed: bool,
}
#[derive(Default)]
pub struct Exports {
pub requests: Vec<Request>,
result: Option<Receiver<Result<String, String>>>,
}
impl Exports {
pub fn load(store: &Store) -> Result<Self, String> {
Ok(Self {
requests: store
.pending_exports()
.map_err(|e| e.to_string())?
.into_iter()
.map(|p| Request {
session: p.session,
start: p.start,
end: p.end,
separate: p.separate,
running: false,
failed: p.failed,
})
.collect(),
result: None,
})
}
pub fn save(&self, store: &Store) -> Result<(), String> {
store
.save_pending_exports(
&self
.requests
.iter()
.map(|r| PendingExport {
session: r.session,
start: r.start,
end: r.end,
separate: r.separate,
failed: r.failed,
})
.collect::<Vec<_>>(),
)
.map_err(|e| e.to_string())
}
pub fn request(
&mut self,
store: &Store,
session: u64,
start: Duration,
end: Duration,
separate: bool,
) -> Result<(), String> {
if end <= start {
return Err("Select a non-empty range".into());
}
if self.requests.len() >= 8 {
return Err("Export queue is full; finish or release existing requests".into());
}
self.requests.push(Request {
session,
start,
end,
separate,
running: false,
failed: false,
});
if let Err(e) = self.save(store) {
self.requests.pop();
return Err(format!("Could not persist export request: {e}"));
}
Ok(())
}
pub fn poll(&mut self, store: &Store) -> Option<String> {
if let Some(receiver) = &self.result {
match receiver.try_recv() {
Ok(result) => {
let index = self.requests.iter().position(|r| r.running).unwrap();
self.result = None;
let notice = match result {
Ok(message) => {
self.requests.remove(index);
message
}
Err(error) => {
self.requests[index].running = false;
self.requests[index].failed = true;
format!("Export failed: {error}. Source range remains protected.")
}
};
if let Err(e) = self.save(store) {
return Some(format!("{notice}; could not persist export queue: {e}"));
}
return Some(notice);
}
Err(mpsc::TryRecvError::Empty) => return None,
Err(mpsc::TryRecvError::Disconnected) => {
self.result = None;
if let Some(r) = self.requests.iter_mut().find(|r| r.running) {
r.running = false;
r.failed = true;
}
let _ = self.save(store);
return Some("Export worker stopped; source range remains protected".into());
}
}
}
for request in &mut self.requests {
if request.failed {
continue;
}
let metadata = match store.metadata(request.session) {
Ok(m) => m,
Err(e) => {
request.failed = true;
let message = format!("Export failed: {e}");
let _ = self.save(store);
return Some(message);
}
};
let available_end = metadata
.segments
.iter()
.filter(|s| s.complete && s.available)
.map(|s| s.end)
.max()
.unwrap_or_default();
let (start, end) = metadata.export_range(request.start, request.end);
if metadata.session.active && available_end < end {
continue;
}
let (sender, receiver) = mpsc::channel();
let store = store.clone();
let separate = request.separate;
match thread::Builder::new()
.name("clipbin-export".into())
.spawn(move || {
let result = (|| {
std::fs::create_dir_all(store.clips_directory())
.map_err(|e| e.to_string())?;
let created = SystemTime::now();
let id = created
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let path = store.clip_path(id);
clipbin_media::export::export(&metadata, start, end, separate, &path)?;
let size_bytes = std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
let saved = SavedClip {
clip: Clip {
id,
game: metadata.session.game.clone(),
created,
length: end.saturating_sub(start),
size_bytes,
},
source_session: metadata.session.id,
start,
end,
separate_audio: separate,
};
if let Err(e) = store.save_clip(&saved) {
let _ = std::fs::remove_file(path);
return Err(e.to_string());
}
Ok(format!(
"Saved clip · {} · {} – {}",
clipbin_core::library::format_length(saved.clip.length),
clipbin_core::library::format_clock(start),
clipbin_core::library::format_clock(end)
))
})();
let _ = sender.send(result);
}) {
Ok(_) => {
request.running = true;
self.result = Some(receiver);
}
Err(e) => {
request.failed = true;
return Some(format!("Could not start export: {e}"));
}
}
break;
}
None
}
pub fn busy(&self) -> bool {
self.result.is_some() || self.requests.iter().any(|r| !r.failed)
}
}
+2
View File
@@ -6,8 +6,10 @@ use gpui::{
mod app;
mod assets;
mod components;
mod exports;
mod recorder_service;
mod screens;
mod storage;
mod text_field;
mod theme;
mod timeline_view;
+308 -6
View File
@@ -2,7 +2,7 @@
use clipbin_core::{
Library, RecordingStatus, Settings,
games::{self, ExitWatch, Game},
library::{Span, SpanKind},
library::{Bookmark, Span, SpanKind},
store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
};
use clipbin_obs::{
@@ -25,6 +25,10 @@ pub struct Snapshot {
pub library: Library,
pub restore_token: Option<String>,
pub exited: bool,
pub notice: Option<String>,
pub metadata: Vec<Metadata>,
pub hotkeys: String,
pub failed_exports: usize,
}
#[derive(Clone)]
pub struct Client {
@@ -36,6 +40,15 @@ enum Command {
ChooseSource,
WindowClosed,
Quit,
Clip(Instant),
Bookmark(Instant),
Export(u64, Duration, Duration),
Release(u64, usize),
Protect(crate::storage::Editing),
DeleteClip(u64),
Open(Option<u64>),
RetryExports,
ReleaseExports,
}
impl Client {
pub fn start(
@@ -51,12 +64,17 @@ impl Client {
library: Library::empty(settings.unprotected_session_limit_bytes),
restore_token: settings.portal_restore_token.clone(),
exited: false,
notice: None,
metadata: Vec::new(),
hotkeys: "Registering global hotkeys…".into(),
failed_exports: 0,
}));
let state = snapshot.clone();
let action_sender = commands.clone();
let worker = thread::Builder::new()
.name("clipbin-recorder".into())
.spawn(move || {
if let Err(error) = worker(settings, receiver, &state) {
if let Err(error) = worker(settings, receiver, &state, action_sender) {
state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
}
})
@@ -70,6 +88,47 @@ impl Client {
pub fn settings(&self, settings: Settings) {
let _ = self.commands.send(Command::Settings(settings));
}
pub fn clip(&self) {
let _ = self.commands.send(Command::Clip(Instant::now()));
}
pub fn bookmark(&self) {
let _ = self.commands.send(Command::Bookmark(Instant::now()));
}
pub fn export(&self, id: u64, start: Duration, end: Duration) {
let _ = self.commands.send(Command::Export(id, start, end));
}
pub fn release(&self, id: u64, index: usize) {
let _ = self.commands.send(Command::Release(id, index));
}
pub fn protect(&self, editing: crate::storage::Editing) {
let _ = self.commands.send(Command::Protect(editing));
}
pub fn delete_clip(&self, id: u64) {
let _ = self.commands.send(Command::DeleteClip(id));
}
pub fn open_clip(&self, id: Option<u64>) {
let _ = self.commands.send(Command::Open(id));
}
pub fn export_range(
&self,
id: u64,
start: Duration,
end: Duration,
) -> Option<(Duration, Duration)> {
self.snapshot
.lock()
.unwrap()
.metadata
.iter()
.find(|m| m.session.id == id)
.map(|m| m.export_range(start, end))
}
pub fn retry_exports(&self) {
let _ = self.commands.send(Command::RetryExports);
}
pub fn release_exports(&self) {
let _ = self.commands.send(Command::ReleaseExports);
}
pub fn choose_source(&self) {
let _ = self.commands.send(Command::ChooseSource);
}
@@ -93,8 +152,10 @@ struct Active {
run: u32,
run_base: Option<Duration>,
span_base: usize,
span_start: Duration,
last_frames: u64,
last_dropped: u64,
audio_samples: Vec<u64>,
fps: u32,
stopping: bool,
ending: bool,
@@ -137,7 +198,14 @@ impl Active {
self.run += 1;
self.run_base = None;
self.span_base = self.metadata.session.spans.len();
self.span_start = self
.metadata
.session
.spans
.last()
.map_or(Duration::ZERO, |s| s.end);
self.last_frames = 0;
self.audio_samples.clear();
self.last_dropped = 0;
self.tracker = StatusTracker::new(
Instant::now(),
@@ -178,6 +246,11 @@ impl Active {
{
segment.end = end.max(segment.start);
}
for index in 0..self.metadata.segments.len() {
if !self.metadata.segments[index].available {
self.metadata.removed(index);
}
}
}
fn finish_run(&mut self, store: &Store) -> Result<(), String> {
let mut empty_tail = false;
@@ -210,6 +283,14 @@ impl Active {
if let Some(segment) = self.metadata.segments.last_mut()
&& !segment.complete
{
if segment.audio_start_samples.is_empty() && segment.frame_start == 0 {
let path = store
.directory(self.metadata.session.id)
.join(&segment.path);
if let Ok(samples) = clipbin_media::export::audio_samples(&path) {
segment.audio_start_samples = vec![0; samples.len()];
}
}
segment.complete = true;
segment.dropped_frames = self.last_dropped;
if self.last_dropped > 0 {
@@ -220,6 +301,12 @@ impl Active {
}
}
self.span_base = self.metadata.session.spans.len();
self.span_start = self
.metadata
.session
.spans
.last()
.map_or(Duration::ZERO, |s| s.end);
self.run_base = None;
self.recording = None;
self.stopping = false;
@@ -254,9 +341,14 @@ fn worker(
mut settings: Settings,
commands: Receiver<Command>,
snapshot: &Arc<Mutex<Snapshot>>,
action_sender: Sender<Command>,
) -> Result<(), String> {
let store = Store::discover().map_err(|e| e.to_string())?;
store.recover().map_err(|e| e.to_string())?;
if let Err(e) = crate::storage::index_legacy_audio(&store) {
snapshot.lock().unwrap().notice =
Some(format!("Could not recover exact audio clocks: {e}"));
}
snapshot.lock().unwrap().library = store
.library(settings.unprotected_session_limit_bytes)
.map_err(|e| e.to_string())?;
@@ -273,6 +365,24 @@ fn worker(
None
}
};
let register = |settings: &Settings| {
let sender = action_sender.clone();
let state = snapshot.clone();
clipbin_core::hotkeys::start(
settings.clip_hotkey,
settings.bookmark_hotkey,
move |action, pressed| {
let _ = sender.send(match action {
clipbin_core::hotkeys::Action::Clip => Command::Clip(pressed),
clipbin_core::hotkeys::Action::Bookmark => Command::Bookmark(pressed),
});
},
move |message| {
state.lock().unwrap().hotkeys = message;
},
)
};
let mut hotkeys = Some(register(&settings));
let mut active: Option<Active> = None;
let mut quitting = false;
let mut finish_after_session = false;
@@ -280,6 +390,9 @@ fn worker(
let mut next_poll = Instant::now();
let mut next_save = Instant::now();
let mut previous_status = None;
let mut exports = crate::exports::Exports::load(&store)?;
let mut editing = None;
let mut next_cleanup = Instant::now();
loop {
match commands.recv_timeout(Duration::from_millis(200)) {
Ok(Command::WindowClosed) => {
@@ -299,6 +412,12 @@ fn worker(
}
}
Ok(Command::Settings(new)) => {
if new.clip_hotkey != settings.clip_hotkey
|| new.bookmark_hotkey != settings.bookmark_hotkey
{
hotkeys.take();
hotkeys = Some(register(&new));
}
settings = new;
snapshot.lock().unwrap().library.storage.budget_bytes =
settings.unprotected_session_limit_bytes;
@@ -307,6 +426,108 @@ fn worker(
}
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
}
Ok(Command::Clip(pressed)) => {
let result = if let Some(a) = &active {
let end = pressed.saturating_duration_since(a.started);
exports.request(
&store,
a.metadata.session.id,
end.saturating_sub(settings.clip_duration),
end,
settings.export_separate_audio,
)
} else {
Err("No active session to clip".into())
};
snapshot.lock().unwrap().notice = Some(result.err().unwrap_or_else(|| {
"Clip queued at press time; waiting for the segment to finish".into()
}));
}
Ok(Command::Bookmark(pressed)) => {
if let Some(a) = &mut active {
a.metadata.session.bookmarks.push(Bookmark {
at: pressed.saturating_duration_since(a.started),
before: settings.bookmark_before,
after: settings.bookmark_after,
protected: true,
});
store.save(&a.metadata).map_err(|e| e.to_string())?;
snapshot.lock().unwrap().notice = Some("Bookmark saved and protected".into());
next_save = Instant::now();
} else {
snapshot.lock().unwrap().notice = Some("No active session to bookmark".into());
}
}
Ok(Command::Export(id, start, end)) => {
snapshot.lock().unwrap().notice = Some(
exports
.request(&store, id, start, end, settings.export_separate_audio)
.err()
.unwrap_or_else(|| "Export queued; source range protected".into()),
);
}
Ok(Command::Release(id, index)) => {
let mut m = store
.read()
.map_err(|e| e.to_string())?
.into_iter()
.find(|m| m.session.id == id);
if let Some(m) = &mut m {
if let Some(b) = m.session.bookmarks.get_mut(index) {
b.protected = false;
}
store.save(m).map_err(|e| e.to_string())?;
if let Some(a) = &mut active
&& a.metadata.session.id == id
{
a.metadata.session.bookmarks = m.session.bookmarks.clone();
}
}
next_cleanup = Instant::now();
}
Ok(Command::Protect(range)) => editing = range,
Ok(Command::DeleteClip(id)) => {
if let Err(e) = store.delete_clip(id) {
snapshot.lock().unwrap().notice = Some(format!("Could not delete clip: {e}"));
}
next_cleanup = Instant::now();
}
Ok(Command::Open(id)) => {
let path = id.map_or_else(|| store.clips_directory(), |id| store.clip_path(id));
let state = snapshot.clone();
thread::spawn(move || {
let result = if id.is_none() {
std::fs::create_dir_all(&path)
.and_then(|()| clipbin_core::clips::open(path))
} else {
clipbin_core::clips::open(path)
};
if let Err(e) = result {
state.lock().unwrap().notice = Some(format!("Could not open: {e}"));
}
});
}
Ok(Command::RetryExports) => {
for r in &mut exports.requests {
r.failed = false;
}
snapshot.lock().unwrap().notice = Some(exports.save(&store).err().map_or_else(
|| "Retrying failed exports".into(),
|e| format!("Could not persist export queue: {e}"),
));
}
Ok(Command::ReleaseExports) => {
let previous = exports.requests.clone();
exports.requests.retain(|r| !r.failed);
snapshot.lock().unwrap().notice =
Some("Failed export source protection released".into());
next_cleanup = Instant::now();
if let Err(e) = exports.save(&store) {
exports.requests = previous;
snapshot.lock().unwrap().notice =
Some(format!("Could not persist released protection: {e}"));
}
}
Ok(Command::ChooseSource) => {
settings.portal_restore_token = None;
if let Err(e) = store::save_settings(&settings) {
@@ -327,11 +548,11 @@ fn worker(
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if (quitting || finish_after_session) && active.is_none() {
if (quitting || finish_after_session) && active.is_none() && !exports.busy() {
break;
}
let now = Instant::now();
if active.is_none() && !quitting && now >= next_scan {
if active.is_none() && !quitting && !finish_after_session && now >= next_scan {
next_scan = now + Duration::from_secs(1);
match games::discover() {
Ok(games) => {
@@ -354,8 +575,10 @@ fn worker(
run: 0,
run_base: None,
span_base: 0,
span_start: Duration::ZERO,
last_frames: 0,
last_dropped: 0,
audio_samples: Vec::new(),
fps: settings.frames_per_second,
stopping: false,
ending: false,
@@ -479,6 +702,30 @@ fn worker(
if let Some(previous) = a.metadata.segments.last_mut()
&& !previous.complete
{
let path =
store.directory(a.metadata.session.id).join(&previous.path);
match clipbin_media::export::audio_samples(&path) {
Ok(samples) => {
if a.audio_samples.is_empty() {
if !previous.audio_start_samples.is_empty() {
a.audio_samples =
previous.audio_start_samples.clone();
} else if previous.frame_start == 0 {
a.audio_samples = vec![0; samples.len()];
}
}
previous.audio_start_samples = a.audio_samples.clone();
for (total, count) in
a.audio_samples.iter_mut().zip(samples)
{
*total += count;
}
}
Err(e) => {
eprintln!("Could not index segment audio: {e}");
a.audio_samples.clear();
}
}
previous.end = offset;
previous.complete = true;
previous.dropped_frames = dropped_frames;
@@ -503,6 +750,7 @@ fn worker(
frame_start: total_frames.saturating_sub(1),
frames_per_second: a.fps,
dropped_frames: 0,
audio_start_samples: a.audio_samples.clone(),
});
}
RecorderEvent::OutputStopped { code, last_error } => {
@@ -542,15 +790,69 @@ fn worker(
drop(state);
snapshot.lock().unwrap().game = None;
active = None;
next_cleanup = now;
} else {
store.save(&a.metadata).map_err(|e| e.to_string())?;
}
match store.library(settings.unprotected_session_limit_bytes) {
Ok(library) => snapshot.lock().unwrap().library = library,
match store.library_with_protection(
settings.unprotected_session_limit_bytes,
&crate::storage::ranges(&exports, editing),
) {
Ok(library) => {
let metadata = store.read().map_err(|e| e.to_string())?;
let mut state = snapshot.lock().unwrap();
state.library = library;
state.metadata = metadata;
}
Err(e) => fail(snapshot, e),
}
}
}
if let Some(message) = exports.poll(&store) {
eprintln!("export_status={message}");
snapshot.lock().unwrap().notice = Some(message);
next_cleanup = now;
}
if now >= next_cleanup {
next_cleanup = now + Duration::from_secs(3);
if let Some(a) = &mut active {
store.save(&a.metadata).map_err(|e| e.to_string())?;
}
match crate::storage::cleanup(
&store,
settings.unprotected_session_limit_bytes,
&exports,
editing,
) {
Ok(metadata) => {
if let Some(a) = &mut active
&& let Some(m) = metadata
.iter()
.find(|m| m.session.id == a.metadata.session.id)
{
a.metadata = m.clone();
a.span_base = a
.metadata
.session
.spans
.iter()
.position(|s| s.start >= a.span_start)
.unwrap_or(a.metadata.session.spans.len());
}
snapshot.lock().unwrap().metadata = metadata;
}
Err(e) => snapshot.lock().unwrap().notice = Some(format!("Cleanup failed: {e}")),
}
match store.library_with_protection(
settings.unprotected_session_limit_bytes,
&crate::storage::ranges(&exports, editing),
) {
Ok(library) => snapshot.lock().unwrap().library = library,
Err(e) => fail(snapshot, e),
}
}
snapshot.lock().unwrap().failed_exports =
exports.requests.iter().filter(|r| r.failed).count();
let status = snapshot.lock().unwrap().status.clone();
if previous_status.as_ref() != Some(&status) {
eprintln!("recorder_status={status:?}");
+50 -6
View File
@@ -32,20 +32,50 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
Some("Open folder"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.notice =
Some("Opening the clips folder comes with the recorder integration.".into());
app.service.open_clip(None);
cx.notify();
}))
.into_any_element(),
],
);
let failed_actions = (app.failed_exports > 0).then(|| {
div()
.flex()
.items_center()
.gap_2()
.child(format!(
"{} failed exports · source footage kept",
app.failed_exports
))
.child(
button("retry-exports", ButtonStyle::Secondary, None, Some("Retry")).on_click(
cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.retry_exports();
cx.notify();
}),
),
)
.child(
button(
"release-exports",
ButtonStyle::Ghost,
None,
Some("Release protection"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.release_exports();
cx.notify();
})),
)
});
if clips.is_empty() {
return div()
.flex()
.flex_col()
.gap_6()
.child(header)
.children(failed_actions)
.child(empty_state(
"scissors",
"No clips yet",
@@ -56,6 +86,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
let now = app.now();
let cards = clips.iter().map(|clip| {
let id = clip.id;
div()
.flex()
.flex_col()
@@ -104,10 +135,22 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
)
.rounded_full()
.on_click(cx.listener(
|app, _: &ClickEvent, _, cx| {
app.notice = Some(
"Opening clips comes with the recorder integration.".into(),
);
move |app, _: &ClickEvent, _, cx| {
app.service.open_clip(Some(id));
cx.notify();
},
)),
)
.child(
button(
("delete-clip", id),
ButtonStyle::Ghost,
None,
Some("Delete"),
)
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id);
cx.notify();
},
)),
@@ -120,6 +163,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.flex_col()
.gap_6()
.child(header)
.children(failed_actions)
.child(
div()
.grid()
+26 -9
View File
@@ -212,7 +212,10 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
.child(transport)
.child(timeline(app, session, cx));
let (export_start, export_end) = export_range(start, end, KEYFRAME_INTERVAL, length);
let (export_start, export_end) = app
.service
.export_range(id, start, end)
.unwrap_or_else(|| export_range(start, end, KEYFRAME_INTERVAL, length));
let clip_panel = panel("CLIP")
.child(row("Start", value(format_clock(start))))
.child(row("End", value(format_clock(end))))
@@ -243,10 +246,10 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
)
.child(row(
"Audio",
value(if app.settings.microphone_enabled {
"Game + mic, mixed".to_owned()
value(if app.settings.export_separate_audio {
"System + mic, if recorded".to_owned()
} else {
"Game audio".to_owned()
"Combined source audio".to_owned()
}),
))
.child(
@@ -258,11 +261,8 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
Some("Export clip"),
)
.flex_1()
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.notice = Some(
"Exporting isn't connected yet; it comes with the recorder integration."
.into(),
);
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.service.export(id, start, end);
cx.notify();
})),
),
@@ -319,6 +319,23 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
"Released"
}),
)
.when(bookmark.protected, |row| {
row.child(
button(
("release-bookmark", index),
ButtonStyle::Ghost,
None,
Some("Release"),
)
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.release(id, index);
cx.stop_propagation();
cx.notify();
},
)),
)
})
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.editor.playhead = at;
app.editor.selection = range;
@@ -93,6 +93,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
)
.child(
section("volume-2", "Audio")
.child(setting("Export separate tracks", "Keep system and microphone tracks for editing; otherwise copy the combined track.",toggle("export-audio", settings.export_separate_audio, |s| s.export_separate_audio = !s.export_separate_audio,cx)))
.child(setting(
"Game audio",
"Everything your speakers play.",
@@ -121,7 +122,9 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.child(labelled("Before", &app.fields.bookmark_before))
.child(labelled("After", &app.fields.bookmark_after)),
))
.child(setting("Hotkeys", "Global hotkeys arrive with the hotkey integration.", value("Not set yet".into()))),
.child(setting("Clip binding", "Ctrl+Shift plus this function key. Changes apply immediately.", segmented("clip-binding",cx,&[("F9",settings.clip_hotkey==9,|s|s.clip_hotkey=9),("F11",settings.clip_hotkey==11,|s|s.clip_hotkey=11)])))
.child(setting("Bookmark binding", "Ctrl+Shift plus this function key. Portal desktops may override it.", segmented("bookmark-binding",cx,&[("F10",settings.bookmark_hotkey==10,|s|s.bookmark_hotkey=10),("F12",settings.bookmark_hotkey==12,|s|s.bookmark_hotkey=12)])))
.child(setting("Hotkeys", "Ctrl+Shift+F9: clip · Ctrl+Shift+F10: bookmark. Wayland's portal handles approval and rebinding.", value(app.hotkeys.clone()))),
)
.child(
section("hard-drive", "Storage")
+124
View File
@@ -0,0 +1,124 @@
//! Cleanup runs only on the recorder, where requests and the current file are known.
use crate::exports::Exports;
use clipbin_core::{
cleanup::{Candidate, select},
store::{Metadata, Store},
};
use std::time::Duration;
pub type Editing = Option<(u64, Duration, Duration)>;
pub fn ranges(exports: &Exports, editing: Editing) -> Vec<(u64, Duration, Duration)> {
let mut ranges: Vec<_> = exports
.requests
.iter()
.map(|r| {
(
r.session,
r.start.saturating_sub(Duration::from_secs(1)),
r.end + Duration::from_secs(1),
)
})
.collect();
if let Some((id, start, end)) = editing {
ranges.push((
id,
start.saturating_sub(Duration::from_secs(1)),
end + Duration::from_secs(1),
));
}
ranges
}
pub fn cleanup(
store: &Store,
budget: u64,
exports: &Exports,
editing: Editing,
) -> Result<Vec<Metadata>, String> {
index_legacy_audio(store)?;
let mut metadata = store.read().map_err(|e| e.to_string())?;
let mut candidates = Vec::new();
let all_ranges = ranges(exports, editing);
for m in &metadata {
let ranges: Vec<_> = all_ranges
.iter()
.filter(|(id, _, _)| *id == m.session.id)
.map(|(_, start, end)| (*start, *end))
.collect();
for (index, s) in m.segments.iter().enumerate().filter(|(_, s)| s.available) {
let protected = clipbin_core::cleanup::protected(s, &m.session.bookmarks, &ranges);
candidates.push(Candidate {
session: m.session.id,
index,
started: m.session.started,
active: m.session.active,
start: s.start,
bytes: std::fs::metadata(store.directory(m.session.id).join(&s.path))
.map_or(0, |f| f.len()),
protected,
complete: s.complete,
});
}
}
for (id, index) in select(&candidates, budget) {
let m = metadata.iter_mut().find(|m| m.session.id == id).unwrap();
let path = store.directory(id).join(&m.segments[index].path);
// Persist unavailability first: a crash must never advertise a deleted file.
let previous = m.clone();
m.removed(index);
store.save(m).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::remove_file(&path) {
*m = previous;
store.save(m).map_err(|e| e.to_string())?;
return Err(format!("Could not clean {}: {e}", path.display()));
}
}
Ok(metadata)
}
/// Upgrade old sessions before cleanup can discard the files needed to recover
/// their separate audio clocks. New recordings index one closed file per split.
pub fn index_legacy_audio(store: &Store) -> Result<(), String> {
for mut m in store.read().map_err(|e| e.to_string())? {
if !m
.segments
.iter()
.any(|s| s.complete && s.available && s.audio_start_samples.is_empty())
{
continue;
}
let mut totals = Vec::new();
for s in &mut m.segments {
if s.frame_start == 0 {
totals.clear();
}
if !s.audio_start_samples.is_empty() {
totals = s.audio_start_samples.clone();
}
if !s.complete {
if !totals.is_empty() {
s.audio_start_samples = totals.clone();
}
continue;
}
if !s.available {
totals.clear();
continue;
}
let samples =
clipbin_media::export::audio_samples(&store.directory(m.session.id).join(&s.path))
.map_err(|e| e.to_string())?;
if totals.is_empty() {
if s.frame_start != 0 {
return Err("Earlier audio clock data is missing".into());
}
totals = vec![0; samples.len()];
}
s.audio_start_samples = totals.clone();
for (total, count) in totals.iter_mut().zip(samples) {
*total += count;
}
}
store.save(&m).map_err(|e| e.to_string())?;
}
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
//! Validation/repair helper: sessions-dir session-id start-ms end-ms output.mp4.
use std::{path::PathBuf, time::Duration};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
if args.len() != 5 {
return Err("Usage: export sessions-dir session-id start-ms end-ms output.mp4".into());
}
let store = clipbin_core::store::Store {
root: PathBuf::from(&args[0]),
};
let metadata = store.metadata(args[1].parse()?)?;
let (start, end) = metadata.export_range(
Duration::from_millis(args[2].parse()?),
Duration::from_millis(args[3].parse()?),
);
clipbin_media::export::export(&metadata, start, end, false, &PathBuf::from(&args[4]))?;
println!(
"Exported {} – {}",
clipbin_core::library::format_clock(start),
clipbin_core::library::format_clock(end)
);
Ok(())
}
+283
View File
@@ -0,0 +1,283 @@
//! Stream-copy export. Container timestamps restart independently at every split;
//! video frames and AAC samples therefore have separate exact clocks.
use crate::{
ffmpeg::{self, Rational, Rescale, codec, encoder, format, media},
init,
};
use clipbin_core::store::Metadata;
use std::{fs, path::Path, time::Duration};
/// Writes and closes MP4 under a temporary name before publishing it.
/// Missing/Removed footage is skipped, retaining a visible timestamp jump.
pub fn export(
metadata: &Metadata,
start: Duration,
end: Duration,
separate_audio: bool,
destination: &Path,
) -> Result<(), String> {
init();
let (start, end) = metadata.export_range(start, end);
let pieces = metadata.covering(start, end);
if pieces.is_empty() {
return Err("No retained footage in this range".into());
}
if pieces.iter().any(|p| p.segment.dropped_frames > 0) {
return Err("This range has dropped frames; exact export placement is unvalidated".into());
}
if pieces
.iter()
.any(|p| p.segment.frames_per_second != pieces[0].segment.frames_per_second)
{
return Err("Export ranges spanning a frame-rate change are unsupported".into());
}
if pieces.iter().any(|p| !p.segment.complete) {
return Err("Footage is still being written".into());
}
let temporary = destination.with_extension("partial.mp4");
let result = remux(metadata, start, end, separate_audio, &temporary);
match result {
Ok(()) => {
fs::File::open(&temporary)
.and_then(|f| f.sync_all())
.map_err(|e| e.to_string())?;
fs::rename(&temporary, destination).map_err(|e| e.to_string())
}
Err(error) => {
let _ = fs::remove_file(&temporary);
Err(error.to_string())
}
}
}
/// Counts AAC samples in a finalised file, without decoding or rounded durations.
pub fn audio_samples(path: &Path) -> Result<Vec<u64>, ffmpeg::Error> {
init();
let mut input = format::input(path)?;
let audio: Vec<_> = input
.streams()
.filter(|s| s.parameters().medium() == media::Type::Audio)
.map(|s| s.index())
.collect();
let mut samples = vec![0; audio.len()];
loop {
let mut packet = ffmpeg::Packet::empty();
match packet.read(&mut input) {
Ok(()) => {}
Err(ffmpeg::Error::Eof) => break,
Err(e) => return Err(e),
}
if let Some(index) = audio.iter().position(|s| *s == packet.stream()) {
samples[index] += 1024;
}
}
Ok(samples)
}
fn audio_start(
metadata: &Metadata,
index: usize,
tracks: usize,
) -> Result<Vec<u64>, ffmpeg::Error> {
let segment = &metadata.segments[index];
if segment.audio_start_samples.len() == tracks {
return Ok(segment.audio_start_samples.clone());
}
if segment.frame_start == 0 {
return Ok(vec![0; tracks]);
}
// Old metadata can be reconstructed while its preceding files still exist.
let mut samples = vec![0; tracks];
for previous in metadata.segments[..index].iter().rev() {
if !previous.available || previous.path.parent() != segment.path.parent() {
return Err(ffmpeg::Error::InvalidData);
}
let counts = audio_samples(&previous.path)?;
if counts.len() != tracks {
return Err(ffmpeg::Error::InvalidData);
}
for (sum, count) in samples.iter_mut().zip(counts) {
*sum += count;
}
if previous.audio_start_samples.len() == tracks {
for (sum, start) in samples.iter_mut().zip(&previous.audio_start_samples) {
*sum += start;
}
return Ok(samples);
}
if previous.frame_start == 0 {
return Ok(samples);
}
}
Err(ffmpeg::Error::InvalidData)
}
fn signed_ticks(time: Duration, origin: Duration, rate: i32) -> i64 {
if time >= origin {
ticks(time - origin, rate)
} else {
-ticks(origin - time, rate)
}
}
fn ticks(time: Duration, rate: i32) -> i64 {
((time.as_nanos() * rate as u128 + 500_000_000) / 1_000_000_000) as i64
}
fn remux(
metadata: &Metadata,
start: Duration,
end: Duration,
separate: bool,
path: &Path,
) -> Result<(), ffmpeg::Error> {
let pieces = metadata.covering(start, end);
let first = format::input(&pieces[0].segment.path)?;
let audio: Vec<_> = first
.streams()
.filter(|s| s.parameters().medium() == media::Type::Audio)
.map(|s| s.index())
.collect();
// OBS writes system, mic, combined in this order; one track is already mixed.
let selected_audio: Vec<_> = if audio.len() == 3 {
if separate {
audio[..2].to_vec()
} else {
vec![audio[2]]
}
} else {
audio.clone()
};
let mut output = format::output(path)?;
let mut mapping = vec![None; first.nb_streams() as usize];
let mut clocks = vec![(0, 0); first.nb_streams() as usize];
for stream in first.streams() {
let video = stream.parameters().medium() == media::Type::Video;
if !video && !selected_audio.contains(&stream.index()) {
continue;
}
let rate = if video {
pieces[0].segment.frames_per_second as i32
} else {
codec::context::Context::from_parameters(stream.parameters())?
.decoder()
.audio()?
.rate() as i32
};
let mut target = output.add_stream(encoder::find(codec::Id::None))?;
target.set_parameters(stream.parameters());
target.set_time_base(Rational(1, rate));
mapping[stream.index()] = Some((target.index(), video, rate));
clocks[stream.index()] = (0, if video { 1 } else { 1024 });
}
output.write_header()?;
let mut previous_end = None;
let mut wrote_video = false;
for piece in pieces {
let adjacent = previous_end == Some(piece.segment.start);
let mut input = format::input(&piece.segment.path)?;
if input.nb_streams() != first.nb_streams() {
return Err(ffmpeg::Error::InvalidData);
}
for stream in input.streams() {
let original = first.stream(stream.index()).unwrap();
if stream.parameters().id() != original.parameters().id() {
return Err(ffmpeg::Error::InvalidData);
}
if stream.parameters().medium() == media::Type::Video {
let current = codec::context::Context::from_parameters(stream.parameters())?
.decoder()
.video()?;
let initial = codec::context::Context::from_parameters(original.parameters())?
.decoder()
.video()?;
if (current.width(), current.height()) != (initial.width(), initial.height()) {
return Err(ffmpeg::Error::InvalidData);
}
}
}
let mut counts = vec![0i64; mapping.len()];
let mut first_pts = vec![None; mapping.len()];
let mut bases = vec![0i64; mapping.len()];
let origins = audio_start(metadata, piece.index, audio.len())?;
let run_base = piece
.segment
.start
.saturating_sub(clipbin_core::store::frame_offset(
piece.segment.frame_start,
piece.segment.frames_per_second,
));
for (index, mapped) in mapping.iter().enumerate() {
if let Some((_, video, rate)) = mapped {
bases[index] = if *video {
if adjacent {
clocks[index].0
} else {
signed_ticks(piece.segment.start, start, *rate)
}
} else {
let ordinal = audio.iter().position(|i| *i == index).unwrap();
signed_ticks(run_base, start, *rate) + origins[ordinal] as i64
};
}
}
loop {
let mut packet = ffmpeg::Packet::empty();
match packet.read(&mut input) {
Ok(()) => {}
Err(ffmpeg::Error::Eof) => break,
Err(e) => return Err(e),
}
let index = packet.stream();
let stream = input.stream(index).ok_or(ffmpeg::Error::StreamNotFound)?;
let Some((target, video, rate)) = mapping[index] else {
continue;
};
let step = clocks[index].1;
let local = if video {
let pts = packet.pts().ok_or(ffmpeg::Error::InvalidData)?;
let origin = *first_pts[index].get_or_insert(pts);
(pts - origin).rescale(stream.time_base(), Rational(1, rate))
} else {
counts[index] * step
};
counts[index] += 1;
let from = ticks(piece.from, rate);
let to = ticks(piece.to, rate);
let pts = bases[index] + local;
if video && (local + step <= from || local >= to) {
continue;
}
if !video && pts >= ticks(end - start, rate) {
continue;
}
if pts < 0 {
continue;
}
let tb = Rational(1, rate);
let out_tb = output.stream(target).unwrap().time_base();
let reorder = packet.pts().unwrap_or(0) - packet.dts().unwrap_or(0);
let delay = reorder.rescale(stream.time_base(), tb);
packet.set_pts(Some(pts));
packet.set_dts(Some(pts - delay));
packet.set_duration(step);
packet.rescale_ts(tb, out_tb);
packet.set_position(-1);
packet.set_stream(target);
packet.write_interleaved(&mut output)?;
wrote_video |= video;
}
for (index, mapped) in mapping.iter().enumerate() {
if let Some((_, true, rate)) = mapped
&& counts[index] != ticks(piece.segment.end - piece.segment.start, *rate)
{
return Err(ffmpeg::Error::InvalidData);
}
}
for index in 0..mapping.len() {
clocks[index].0 = bases[index] + counts[index] * clocks[index].1;
}
previous_end = Some(piece.segment.end);
}
if !wrote_video {
return Err(ffmpeg::Error::InvalidData);
}
output.write_trailer()
}
+2
View File
@@ -67,3 +67,5 @@ mod tests {
println!("{probe:?}");
}
}
pub mod export;
+108
View File
@@ -262,3 +262,111 @@ active, it exits immediately. It stops discovering new games once closed,
so there is no indefinitely running invisible app. Reopening the window and
persistent background discovery need the future tray integration. Explicit
Quit/Ctrl+Q stops and finalises the current recording immediately.
## Clips, bookmarks and storage cleanup
`clipbin-media::export` copies video and selected AAC tracks to MP4 without
re-encoding. OBS already writes system, microphone and combined tracks when the
mic is enabled: exports default to the combined track, with a Settings option
for the two separate tracks. A session without a mic exports its single track.
The output is closed and synced under a `.partial.mp4` name, then renamed;
only the completed MP4 and saved JSON appear in the Clips library.
Video timestamps use integer frame clocks, retaining presentation/decode
ordering for B-frames. Each segment also persists cumulative AAC sample starts
per stream (`audio_start_samples`, with `#[serde(default)]`). Audio uses its own
1024-sample AAC packet clock across adjacent pieces, avoiding independently reset/rounded Matroska timestamps.
Old session metadata defaults these audio counters to empty. Before cleanup,
the worker reconstructs them from retained finalised files; export can also
reconstruct an older range while its preceding files still exist. If the earlier
files are already unavailable and no exact clock was saved, export fails
honestly rather than guessing the A/V phase.
Keyframes are anchored to the capture run, including its wall-clock offset.
Outer unavailable footage is trimmed from completed ranges. Internal Missing
and Removed spans retain a timestamp jump; players may hold the preceding
picture or skip to the next picture, but the gap is not collapsed. Export
ranges spanning codec, size or frame-rate changes fail honestly. Dropped-frame
placement remains unvalidated, so these ranges fail rather than claiming exact
CFR output.
One bounded export queue runs on a separate thread. Requests retain the action
receipt timestamp while the current segment finalises; cleanup leases are
persisted in `clips/export-requests.json`. Failed exports retain their leases
until retry succeeds or the user releases them from the Clips screen. A crash
between MP4 publication and JSON persistence can leave an unlisted complete MP4;
Open folder lets the user recover it. Partial files are never listed as clips.
Cleanup checks the byte budget every three seconds and after session end. Its
pure selector orders completed sessions first, then active footage, oldest
first. It excludes bookmarks, editor selection and queued/running/failed export
ranges, and never selects the current file. It persists Removed spans before
deleting a segment; a deletion failure restores availability and surfaces an
error. Bookmarks persist in session metadata, protect future post-roll, and can
be released in the editor. Clips remain outside this budget and can be deleted
explicitly.
Global bindings default to Ctrl+Shift+F9 (clip) and Ctrl+Shift+F10 (bookmark).
Settings independently offer F11/F12 alternatives; configuration files accept
F1–F12 (distinct bindings). Wayland uses the GlobalShortcuts portal and displays
its returned binding descriptions; the desktop handles approval and rebinding.
A compositor without that portal reports unavailability, with sidebar buttons
as the fallback. X11 uses XGrabKey with Caps/Num lock variants and detectable
auto-repeat; Windows uses RegisterHotKey with MOD_NOREPEAT on a message thread.
Action timestamps are taken when the native event is received and retained
while the worker queues the export. Real Hyprland first-use approval, portal
restoration/rebinding and shortcuts while a game has focus remain unvalidated.
Native X11 desktop behaviour and Windows registration/conflicts also remain
unvalidated.
New checks:
- `scripts/validate/linux-clips.sh`: fake Steam session, actual sidebar button
clicks, cross-segment MP4, codec preservation, zero-error decode, CFR packet
PTS spacing, continuous audio, original short-clip range, capture continuing
during export, a persisted bookmark, forced one-byte cleanup with neighbouring
Removed spans, a new export of the bookmark after earlier files are deleted,
and a Clips screenshot. Uses only the standard PATH tools,
`rig-pointer` and `wtype`; no ripgrep dependency.
- `scripts/validate/check-clip.py <saved-clip.json>`: reusable packet/decode check
before source cleanup, including selected audio track count, cumulative source
audio clocks and the original per-track A/V phase to within one audio sample.
- `scripts/validate/linux-hotkeys.sh`: actual clip/bookmark X11 key grabs under
Xwayland (`xdotool` injects native X11 keys). The wlr rig has no GlobalShortcuts
portal, so it cannot validate the Wayland shortcut route.
Windows media build setup: use an FFmpeg **7.1 shared development** package
(headers and MSVC import libraries) matching OBS's bundled avcodec-61 DLLs.
Set `FFMPEG_DIR` to that package when building `clipbin-media`; distribute the
matching OBS-bundled FFmpeg DLLs. FFmpeg development/import files for MSVC are
not available in this Linux environment, so `clipbin-media` is excluded from
the Windows cross-lint. `clipbin-core` (including the hotkey bridge),
`clipbin-obs` and `clipbin-probe` are cross-linted. Windows export/remux, the
default player/folder actions, clip deletion, pending-export leases, bookmark
release, cleanup of recently closed files and every new GPUI control still
need linking/running on Windows.
Step 2 validation on the Intel UHD 770 Linux rig (2026-10-08): workspace format,
clippy with `-D warnings`, and all 49 unit tests passed. Windows cross-clippy
passed for core, OBS and probe with the exclusions above. The end-to-end clips
script passed for H.264 with one audio track and with separate system/mic
tracks, and for HEVC with the combined track. The HEVC run requested the default
120 seconds from a shorter session and correctly saved its available 14 seconds.
All checked exports retained their codec, decoded without errors, had exact
1/60-second video spacing and continuous audio, and retained their source A/V
phase. Forced one-byte cleanup removed neighbouring footage while bookmarks
and exported MP4s survived; a retained bookmark could still export after its
preceding files had been deleted. Clips screenshots are produced as `clips.png`
in each script's output directory.
To repeat the extra audio/codec cases, set `CLIPBIN_VALIDATE_MIC=1`, optionally
`CLIPBIN_VALIDATE_SEPARATE_AUDIO=1`, `CLIPBIN_VALIDATE_CODEC=Hevc`, or
`CLIPBIN_VALIDATE_CLIP_SECONDS=120` before `linux-clips.sh`. The original nine
capture scenarios and automatic-session validation also passed using
`env PATH=/usr/bin:/bin:$HOME/.cargo/bin`. Native X11 hotkey registration and
both actions passed under Xwayland. These fake Steam tests run sequentially:
game discovery is machine-wide, although rig teardown only kills its own PIDs.
AV1 export is unvalidated because this GPU has no usable AV1 encoder. B-frame
export and ranges containing an internal timestamp gap need additional media
fixtures; dropped-frame ranges are explicitly rejected. Linux default-player
and file-manager associations still need testing on a real desktop.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate a persisted clip before any source cleanup (ffmpeg/ffprobe required)."""
import json
import pathlib
import subprocess
import sys
saved_path = pathlib.Path(sys.argv[1])
saved = json.loads(saved_path.read_text())
clip = saved_path.with_suffix('.mp4')
session_path = saved_path.parent.parent / 'sessions' / str(saved['source_session']) / 'session.json'
session = json.loads(session_path.read_text())
seconds = lambda d: d['secs'] + d['nanos'] / 1e9
start, end = seconds(saved['start']), seconds(saved['end'])
assert end > start
pieces = [s for s in session['segments'] if s['available'] and seconds(s['start']) < end and seconds(s['end']) > start]
assert len(pieces) >= 2, 'Export must cross a segment boundary'
def probe(path):
return json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_packets', '-of', 'json', str(path)]))
source = probe(session_path.parent / pieces[0]['path'])
output = probe(clip)
video = next(s for s in output['streams'] if s['codec_type'] == 'video')
source_video = next(s for s in source['streams'] if s['codec_type'] == 'video')
assert video['codec_name'] == source_video['codec_name']
pts = sorted(float(p['pts_time']) for p in output['packets'] if p['stream_index'] == video['index'])
fps = pieces[0]['frames_per_second']
for a, b in zip(pts, pts[1:]):
assert abs(b-a-1/fps) < 2e-6, ('Video gap/duplicate', a, b, fps)
assert abs(pts[0]) < 1/fps
assert abs(pts[-1]+1/fps-(end-start)) < 1/fps+0.002, (pts[-1], start, end)
# Metadata records independent AAC epochs, even if earlier files are cleaned up later.
runs={}
for segment in session['segments']:
if not segment['complete'] or not segment['available']:
continue
run=str(pathlib.Path(segment['path']).parent)
facts=probe(session_path.parent/segment['path'])
audios=[s for s in facts['streams'] if s['codec_type']=='audio']
totals=runs.setdefault(run,[0]*len(audios))
assert segment['audio_start_samples']==totals,(segment['path'],segment['audio_start_samples'],totals)
for i,stream in enumerate(audios):
totals[i]+=1024*len([p for p in facts['packets'] if p['stream_index']==stream['index']])
for ordinal,stream in enumerate([s for s in output['streams'] if s['codec_type']=='audio']):
if stream['codec_type'] == 'audio':
timestamps = [float(p['pts_time']) for p in output['packets'] if p['stream_index'] == stream['index']]
step = 1024 / int(stream['sample_rate'])
assert timestamps
import math
source_ordinal=ordinal if saved['separate_audio'] or len([s for s in source['streams'] if s['codec_type']=='audio'])!=3 else 2
first=pieces[0]
run_base=seconds(first['start'])-first['frame_start']/fps
phase=run_base+first['audio_start_samples'][source_ordinal]/int(stream['sample_rate'])-start
expected_first=phase+max(0,math.ceil(-phase/step))*step
assert abs(timestamps[0]-expected_first)<1/int(stream['sample_rate'])+2e-6,('A/V phase',timestamps[0],expected_first)
for a, b in zip(timestamps, timestamps[1:]):
assert abs(b-a-step) < 2e-6, ('Audio gap/duplicate', a, b)
source_audio = [s for s in source['streams'] if s['codec_type'] == 'audio']
expected = 2 if saved['separate_audio'] and len(source_audio) == 3 else 1
assert len([s for s in output['streams'] if s['codec_type'] == 'audio']) == expected
result = subprocess.run(['ffmpeg', '-v', 'error', '-xerror', '-i', str(clip), '-f', 'null', '-'], capture_output=True)
assert result.returncode == 0 and not result.stderr, result.stderr.decode()
assert saved['clip']['size_bytes'] == clip.stat().st_size
print(f'PASS clip {clip.name}: {video["codec_name"]}, {len(pts)} CFR frames, continuous audio, zero decode errors, {end-start:.3f}s')
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Real desktop buttons, background export, persisted bookmark and forced cleanup.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-/tmp/clipbin-clips-validation-$(date +%s)}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop -p rig-pointer --manifest-path "${repo}/Cargo.toml"
cargo build --locked -p clipbin-media --example export --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_CLIPS_REPO="${repo}" CLIPBIN_CLIPS_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_CLIPS_REPO}"; out="${CLIPBIN_CLIPS_OUT}"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_CONFIG_HOME}/clipbin"
cat > "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'JSON'
{"clip_duration":{"secs":5,"nanos":0},"bookmark_before":{"secs":2,"nanos":0},"bookmark_after":{"secs":2,"nanos":0}}
JSON
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
import json,os,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text())
s['clip_duration']['secs']=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
s['codec']=os.environ.get('CLIPBIN_VALIDATE_CODEC','H264')
assert s['codec'] in ('H264','Hevc','Av1')
s['microphone_enabled']=os.environ.get('CLIPBIN_VALIDATE_MIC','0')=='1'
s['export_separate_audio']=os.environ.get('CLIPBIN_VALIDATE_SEPARATE_AUDIO','0')=='1'
p.write_text(json.dumps(s))
PYSETTINGS
"${repo}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & app=$!
trap 'kill "${app}" "${game:-}" 2>/dev/null || true' EXIT
quit_app() {
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
# A fresh GPUI window may not have received the rig's keyboard focus yet.
# Move/click inside it before injecting the shortcut, then retry boundedly.
read -r qx qy qw qh < <(geometry)
click "$((qx+qw-30))" "$((qy+100))"
sleep 0.5
wtype -M ctrl -k q -m ctrl
for attempt in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi
if ((attempt % 4 == 0)); then
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
wtype -M ctrl -k q -m ctrl
fi
sleep 1
done
grim "${out}/quit-failed.png"
swaymsg -t get_tree > "${out}/quit-failed-tree.json"
echo 'FAIL desktop did not honour Quit' >&2
exit 1
}
sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null || true
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' >/dev/null
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
for _ in $(seq 30); do
if grep -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'recorder_status=Recording' "${out}/desktop.log"
sleep 5
# Read window geometry rather than depending on output placement/titlebar height.
geometry() { swaymsg -t get_tree | python3 -c 'import json,sys
s=[json.load(sys.stdin)]
while s:
n=s.pop()
if n.get("app_id")=="dev.clipbin.Clipbin":
r=n["rect"];print(r["x"],r["y"],r["width"],r["height"]);break
s.extend(n.get("nodes",[])+n.get("floating_nodes",[]))'; }
read -r x y width height < <(geometry)
click() { printf 'move %s %s\nclick\nsleep 0.3\n' "$1" "$2" | "${repo}/target/debug/rig-pointer" 1920 1080; }
grim "${out}/before-buttons.png"
# Bottom sidebar: storage 88px, recorder 70px, gap24px, bookmark32px.
click "$((x+110))" "$((y+height-248))"
sleep 5
press_ns="$(python3 -c 'import time;print(time.time_ns())')"
click "$((x+110))" "$((y+height-304))"
for _ in $(seq 30); do
if grep -q 'export_status=Saved clip' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'export_status=Saved clip' "${out}/desktop.log"
python3 - "${XDG_DATA_HOME}" "${repo}" "${press_ns}" <<'PY'
import pathlib,subprocess,sys,json,os
clips=[p for p in pathlib.Path(sys.argv[1]).glob('clipbin/clips/*.json') if p.stem.isdigit()]
assert len(clips)==1,clips
subprocess.run(['python3',sys.argv[2]+'/scripts/validate/check-clip.py',str(clips[0])],check=True)
m=json.loads(next(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')).read_text())
assert len(m['session']['bookmarks'])==1 and m['session']['bookmarks'][0]['protected']
assert m['session']['active'],'Export must leave capture active'
saved=json.loads(clips[0].read_text())
configured=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
seconds=lambda d:d['secs']+d['nanos']/1e9
pressed=int(sys.argv[3])/1e9-m['session']['started']['secs_since_epoch']-m['session']['started']['nanos_since_epoch']/1e9
assert pressed-.2<=seconds(saved['end'])<=pressed+1.2,(pressed,saved['end'])
if pressed>=configured:
assert pressed-configured-1.2<=seconds(saved['start'])<=pressed-configured+.2,(pressed,saved['start'])
assert configured<=seconds(saved['clip']['length'])<=configured+1.1
else:
assert abs(seconds(saved['start'])-min(seconds(s['start']) for s in m['segments'] if s['available']))<.001
assert seconds(saved['clip']['length'])<configured
print('PASS shorter session used its actual available footage')
print('PASS export kept the original button press range while the segment finalised')
PY
click "$((x+100))" "$((y+128))"
sleep 2; grim "${out}/clips.png"
kill "${game}"; wait "${game}" 2>/dev/null || true
sleep 3
quit_app
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PY'
import json,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text());s['unprotected_session_limit_bytes']=1;p.write_text(json.dumps(s))
PY
"${repo}/target/debug/clipbin-desktop" > "${out}/cleanup.log" 2>&1 & app=$!
sleep 6
python3 - "${XDG_DATA_HOME}" "${repo}" "${out}" <<'PY'
import json,pathlib,sys,subprocess
root=pathlib.Path(sys.argv[1]);p=next(root.glob('clipbin/sessions/*/session.json'));m=json.loads(p.read_text())
seconds=lambda d:d['secs']+d['nanos']/1e9
b=m['session']['bookmarks'][0];start=max(0,seconds(b['at'])-seconds(b['before']));end=min(seconds(m['session']['length']),seconds(b['at'])+seconds(b['after']))
assert b['protected'];removed=kept=0
for s in m['segments']:
overlap=seconds(s['start'])<end and seconds(s['end'])>start
if overlap:
assert s['available'] and (p.parent/s['path']).is_file();kept+=1
elif seconds(s['end'])>seconds(s['start']):
assert not s['available'] and not (p.parent/s['path']).exists();removed+=1
assert kept and removed,(kept,removed)
assert any(s['kind']=='Removed' for s in m['session']['spans'])
assert len(list(root.glob('clipbin/clips/*.mp4')))==1
print('PASS forced tiny-budget cleanup preserved bookmark footage and clip, removed neighbours and persisted Removed spans')
# Earlier unprotected files are gone: the retained bookmark must still remux
# using its persisted per-stream clock, without scanning those deleted files.
export=pathlib.Path(sys.argv[3])/'bookmark-after-cleanup.mp4'
subprocess.run([sys.argv[2]+'/target/debug/examples/export',str(p.parent.parent),str(m['session']['id']),str(int(start*1000)),str(int(end*1000)),str(export)],check=True)
subprocess.run(['ffmpeg','-v','error','-xerror','-i',str(export),'-f','null','-'],check=True)
packets=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_packets','-of','json',str(export)]))['packets']
pts=sorted(float(p['pts_time']) for p in packets);fps=next(s['frames_per_second'] for s in m['segments'] if s['available'])
assert all(abs(b-a-1/fps)<2e-6 for a,b in zip(pts,pts[1:]))
print('PASS protected bookmark exported after preceding source segments were deleted')
PY
quit_app
echo 'PASS in-app clip and bookmark paths; portal GlobalShortcuts unavailable in wlr rig (real Hyprland remains unvalidated)'
INNER
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# X11 native grabs under the rig's Xwayland; portal actions need real Hyprland.
set -euo pipefail
command -v xdotool >/dev/null || { echo "Install xdotool to inject X11 hotkeys" >&2; exit 127; }
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cargo build --locked -p clipbin-core --example hotkeys --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_HOTKEY_REPO="${repo}"
"${repo}/scripts/rig/headless.sh" --x11 bash <<'INNER'
set -euo pipefail
log="${CLIPBIN_RIG_DIR}/hotkeys.log"
env -u WAYLAND_DISPLAY "${CLIPBIN_HOTKEY_REPO}/target/debug/examples/hotkeys" > "${log}" 2>&1 & helper=$!
trap 'kill "${helper}" 2>/dev/null || true' EXIT
sleep 2
swaymsg '[title="clipbin-test-game"] focus' > /dev/null
xdotool key ctrl+shift+F9
sleep 0.3
xdotool key ctrl+shift+F10
if ! wait "${helper}"; then cat "${log}"; exit 1; fi
cat "${log}"
grep -q 'PASS native clip and bookmark hotkeys' "${log}"
INNER