feat: automatically record Steam game sessions

Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit.

Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
This commit is contained in:
2026-10-08 17:30:40 +02:00
parent 98fe898efb
commit 435ddf2b17
21 changed files with 1872 additions and 38 deletions
+15 -2
View File
@@ -8,5 +8,18 @@ license.workspace = true
repository.workspace = true
publish.workspace = true
[lints]
workspace = true
[lints.rust]
unsafe_code = "deny"
[dependencies]
dirs = "5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
[target.'cfg(target_os = "linux")'.dependencies]
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"] }
+362
View File
@@ -0,0 +1,362 @@
//! Steam discovery is independent of both the interface and the recorder.
use std::{
fs, io,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Game {
pub app_id: u32,
pub pid: u32,
pub name: String,
pub executable: String,
}
/// `/proc` cmdlines are NUL-separated argv, not shell command strings.
pub fn reaper_app_id(bytes: &[u8]) -> Option<u32> {
let argv: Vec<_> = bytes
.split(|b| *b == 0)
.filter_map(|s| std::str::from_utf8(s).ok())
.collect();
if Path::new(argv.first()?).file_name()?.to_str()? != "reaper" || !argv.contains(&"SteamLaunch")
{
return None;
}
argv.iter()
.find_map(|s| s.strip_prefix("AppId=").and_then(parse_running_app_id))
}
pub fn parse_running_app_id(value: &str) -> Option<u32> {
value.trim().parse::<u32>().ok().filter(|id| *id > 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VdfValue {
Text(String),
Object(Vec<(String, VdfValue)>),
}
impl VdfValue {
pub fn get(&self, key: &str) -> Option<&Self> {
match self {
Self::Object(items) => items
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v),
_ => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s),
_ => None,
}
}
}
/// Steam's quoted strings, comments, and unquoted keys; unknown keys are kept.
pub fn parse_vdf(input: &str) -> Option<VdfValue> {
let mut tokens = vec![];
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
if c == '/' && chars.peek() == Some(&'/') {
for c in chars.by_ref() {
if c == '\n' {
break;
}
}
continue;
}
if c == '{' || c == '}' {
tokens.push(c.to_string());
continue;
}
let mut token = String::new();
if c == '"' {
let mut closed = false;
while let Some(c) = chars.next() {
if c == '"' {
closed = true;
break;
}
if c == '\\' {
let next = chars.next()?;
if next != '"' && next != '\\' {
token.push('\\');
}
token.push(next);
} else {
token.push(c);
}
}
if !closed {
return None;
}
} else {
token.push(c);
while chars
.peek()
.is_some_and(|c| !c.is_whitespace() && *c != '{' && *c != '}')
{
token.push(chars.next()?);
}
}
tokens.push(token);
}
fn object(tokens: &[String], at: &mut usize, nested: bool, depth: usize) -> Option<VdfValue> {
if depth > 32 {
return None;
}
let mut entries = vec![];
while let Some(key) = tokens.get(*at) {
if key == "}" {
if !nested {
return None;
}
*at += 1;
return Some(VdfValue::Object(entries));
}
if key == "{" {
return None;
}
*at += 1;
let value = tokens.get(*at)?;
*at += 1;
let value = if value == "{" {
object(tokens, at, true, depth + 1)?
} else if value == "}" {
return None;
} else {
VdfValue::Text(value.clone())
};
entries.push((key.clone(), value));
}
if nested {
None
} else {
Some(VdfValue::Object(entries))
}
}
object(&tokens, &mut 0, false, 0)
}
fn steam_root() -> Option<PathBuf> {
#[cfg(windows)]
{
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey("Software\\Valve\\Steam")
.ok()?
.get_value::<String, _>("SteamPath")
.ok()
.map(PathBuf::from)
}
#[cfg(not(windows))]
{
let home = dirs::home_dir()?;
[
home.join(".steam/steam"),
home.join(".local/share/Steam"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"),
]
.into_iter()
.find(|p| p.exists())
}
}
fn libraries(root: &Path) -> Vec<PathBuf> {
let mut result = vec![root.to_path_buf()];
if let Some(vdf) = fs::read_to_string(root.join("steamapps/libraryfolders.vdf"))
.ok()
.and_then(|s| parse_vdf(&s))
&& let Some(VdfValue::Object(entries)) = vdf.get("libraryfolders")
{
for (key, value) in entries {
if key.parse::<u32>().is_ok()
&& let Some(path) = value
.get("path")
.and_then(VdfValue::text)
.or_else(|| value.text())
{
result.push(PathBuf::from(path));
}
}
}
result.sort();
result.dedup();
result
}
fn manifest(app_id: u32) -> Option<(String, PathBuf)> {
for root in libraries(&steam_root()?) {
let Some(vdf) =
fs::read_to_string(root.join(format!("steamapps/appmanifest_{app_id}.acf")))
.ok()
.and_then(|s| parse_vdf(&s))
else {
continue;
};
let app = vdf.get("AppState")?;
let name = app.get("name")?.text()?.to_string();
let install = root
.join("steamapps/common")
.join(app.get("installdir")?.text()?);
return Some((name, install));
}
None
}
#[cfg(target_os = "linux")]
pub fn discover() -> io::Result<Vec<Game>> {
let mut games = vec![];
for entry in fs::read_dir("/proc")?.flatten() {
let Some(pid) = entry.file_name().to_str().and_then(|s| s.parse().ok()) else {
continue;
};
if let Ok(bytes) = fs::read(entry.path().join("cmdline"))
&& let Some(app_id) = reaper_app_id(&bytes)
{
games.push(Game {
app_id,
pid,
name: manifest(app_id).map_or_else(|| format!("App {app_id}"), |m| m.0),
executable: String::new(),
});
}
}
games.sort_by_key(|g| g.pid);
Ok(games)
}
#[cfg(target_os = "linux")]
pub struct ExitWatch {
fd: rustix::fd::OwnedFd,
}
#[cfg(target_os = "linux")]
impl ExitWatch {
pub fn open(game: &Game) -> io::Result<Self> {
let pid = rustix::process::Pid::from_raw(game.pid as i32)
.ok_or_else(|| io::Error::other("Invalid process id"))?;
let fd = rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty())?;
// Check identity after opening: a recycled PID must not attach to another game.
if reaper_app_id(&fs::read(format!("/proc/{}/cmdline", game.pid))?) != Some(game.app_id) {
return Err(io::Error::other("Game exited during discovery"));
}
Ok(Self { fd })
}
pub fn exited(&mut self) -> io::Result<bool> {
use rustix::event::{PollFd, PollFlags, Timespec, poll};
let mut fds = [PollFd::new(&self.fd, PollFlags::IN)];
Ok(poll(
&mut fds,
Some(&Timespec {
tv_sec: 0,
tv_nsec: 0,
}),
)? > 0)
}
}
#[cfg(windows)]
pub fn discover() -> io::Result<Vec<Game>> {
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
let steam = match RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Valve\\Steam") {
Ok(key) => key,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(error) => return Err(error),
};
let app_id = steam
.get_value::<u32, _>("RunningAppID")
.ok()
.filter(|id| *id > 0)
.or_else(|| {
steam
.get_value::<String, _>("RunningAppID")
.ok()
.and_then(|s| parse_running_app_id(&s))
});
let Some(app_id) = app_id else {
return Ok(vec![]);
};
if let Ok(app) = steam.open_subkey(format!("Apps\\{app_id}"))
&& app.get_value::<u32, _>("Running").ok() == Some(0)
{
return Ok(vec![]);
}
let (name, directory) = manifest(app_id)
.ok_or_else(|| io::Error::other(format!("Cannot locate installation for App {app_id}")))?;
Ok(crate::windows_process::processes_in(&directory)?
.into_iter()
.map(|(pid, executable)| Game {
app_id,
pid,
name: name.clone(),
executable,
})
.collect())
}
#[cfg(windows)]
pub struct ExitWatch {
watch: crate::windows_process::ExitWatch,
}
#[cfg(windows)]
impl ExitWatch {
pub fn open(game: &Game) -> io::Result<Self> {
Ok(Self {
watch: crate::windows_process::ExitWatch::open(game.pid, &game.executable)?,
})
}
pub fn exited(&mut self) -> io::Result<bool> {
self.watch.exited()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cmdline_matches_only_the_steam_reaper() {
assert_eq!(
reaper_app_id(b"/usr/bin/reaper\0SteamLaunch\0AppId=480\0--\0sleep\0"),
Some(480)
);
for bytes in [
b"sleep\0SteamLaunch\0AppId=480\0".as_slice(),
b"reaper\0AppId=480\0",
b"reaper\0SteamLaunch\0AppId=bad\0",
b"reaper\0SteamLaunch\0AppId=0\0",
] {
assert_eq!(reaper_app_id(bytes), None);
}
}
#[test]
fn registry_values_are_positive_app_ids() {
assert_eq!(parse_running_app_id(" 480 "), Some(480));
for value in ["0", "-1", "4294967296", "abc", ""] {
assert_eq!(parse_running_app_id(value), None);
}
}
#[test]
fn manifests_and_library_folders_are_tolerant() {
let app = parse_vdf("// comment\n\"AppState\" { appid 480 \"name\" \"Space \\\"War\\\"\" installdir Spacewar }").unwrap();
assert_eq!(
app.get("appstate").unwrap().get("name").unwrap().text(),
Some("Space \"War\"")
);
let folders = parse_vdf(
r#""libraryfolders" { "0" { "path" "C:\\Steam" "apps" { "480" "1" } } "1" "/games" }"#,
)
.unwrap();
assert_eq!(
folders
.get("libraryfolders")
.unwrap()
.get("0")
.unwrap()
.get("path")
.unwrap()
.text(),
Some("C:\\Steam")
);
assert!(parse_vdf("root { name \"unterminated").is_none());
assert!(parse_vdf("root { name value").is_none());
}
}
+8
View File
@@ -7,3 +7,11 @@ mod status;
pub use library::Library;
pub use settings::{Settings, VideoCodec};
pub use status::RecordingStatus;
pub mod games;
pub mod store;
#[cfg(windows)]
mod windows_process;
pub mod segments;
+10 -9
View File
@@ -1,18 +1,19 @@
//! Recorded sessions, bookmarks and exported clips as the interface shows them.
//!
//! Persistence comes with the recorder integration; until then the library is
//! empty, or filled with `Library::sample()` for interface work.
//! Loaded from the session store, or explicitly filled with `Library::sample()`
//! for interface work.
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Library {
pub sessions: Vec<Session>,
pub clips: Vec<Clip>,
pub storage: StorageUsage,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Session {
pub id: u64,
pub game: String,
@@ -25,14 +26,14 @@ pub struct Session {
pub bookmarks: Vec<Bookmark>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
pub start: Duration,
pub end: Duration,
pub kind: SpanKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpanKind {
Footage,
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
@@ -42,7 +43,7 @@ pub enum SpanKind {
Removed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bookmark {
pub at: Duration,
pub before: Duration,
@@ -60,7 +61,7 @@ impl Bookmark {
}
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Clip {
pub id: u64,
pub game: String,
@@ -71,7 +72,7 @@ pub struct Clip {
/// SPEC: the budget counts only unprotected session footage; protected
/// bookmarks and exported clips are shown separately.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageUsage {
pub budget_bytes: u64,
pub unprotected_bytes: u64,
+30
View File
@@ -0,0 +1,30 @@
//! A split racing with stop can leave only a Matroska header, with no footage.
use std::{
fs::File,
io::{self, Read},
path::Path,
};
/// The pinned muxer writes its first Cluster immediately after a small header.
/// Inspect only that prefix, so checking a completed segment never reads its footage.
pub fn has_cluster(path: &Path) -> io::Result<bool> {
cluster_in(File::open(path)?)
}
fn cluster_in(reader: impl Read) -> io::Result<bool> {
let mut prefix = Vec::new();
reader.take(64 * 1024).read_to_end(&mut prefix)?;
Ok(prefix.windows(4).any(|id| id == [0x1f, 0x43, 0xb6, 0x75]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distinguishes_a_header_only_tail_from_media() {
let header = [0x1a, 0x45, 0xdf, 0xa3, 0x80, 0x18, 0x53, 0x80, 0x67, 0xff];
assert!(!cluster_in(header.as_slice()).unwrap());
let mut media = header.to_vec();
media.extend([0x1f, 0x43, 0xb6, 0x75, 0x84, 0xe7, 0x82, 0, 0]);
assert!(cluster_in(media.as_slice()).unwrap());
}
}
+7 -3
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::{fmt, time::Duration};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VideoCodec {
#[default]
H264,
@@ -18,11 +19,13 @@ impl fmt::Display for VideoCodec {
}
}
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
#[derive(Debug, Clone, PartialEq, Eq)]
/// Persisted recorder preferences from SPEC.md.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// A fixed recording size. `None` records at the captured window's or
/// monitor's own size, following its orientation.
pub portal_restore_token: Option<String>,
pub resolution: Option<(u32, u32)>,
pub frames_per_second: u32,
pub codec: VideoCodec,
@@ -39,6 +42,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
portal_restore_token: None,
resolution: None,
frames_per_second: 60,
codec: VideoCodec::H264,
+347
View File
@@ -0,0 +1,347 @@
//! Atomic session metadata; segment paths are relative to their session directory.
use crate::{
Library, Settings,
library::{Session, Span, SpanKind},
};
use serde::{Deserialize, Serialize};
use std::{
fs, io,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Segment {
pub path: PathBuf,
pub start: Duration,
pub end: Duration,
pub complete: bool,
/// False for a header-only file left by a split racing with stop.
#[serde(default = "available_default")]
pub available: bool,
#[serde(default)]
pub frame_start: u64,
#[serde(default = "default_fps")]
pub frames_per_second: u32,
/// Cumulative dropped frames in this run when the segment completed.
#[serde(default)]
pub dropped_frames: u64,
}
fn available_default() -> bool {
true
}
fn default_fps() -> u32 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
pub session: Session,
pub app_id: u32,
pub segments: Vec<Segment>,
}
#[derive(Clone)]
pub struct Store {
pub root: PathBuf,
}
pub fn config_path() -> io::Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or_else(|| io::Error::other("No configuration directory"))?
.join("clipbin/settings.json"))
}
pub fn load_settings() -> io::Result<Settings> {
match fs::read(config_path()?) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(e),
}
}
pub fn save_settings(settings: &Settings) -> io::Result<()> {
atomic_json(&config_path()?, settings)
}
/// Same-directory replacement keeps readers and crash recovery away from partial JSON.
pub fn atomic_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
fs::create_dir_all(
path.parent()
.ok_or_else(|| io::Error::other("No parent directory"))?,
)?;
let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
serde_json::to_writer_pretty(temporary.as_file_mut(), value).map_err(io::Error::other)?;
temporary.as_file().sync_all()?;
temporary.persist(path).map_err(|e| e.error)?;
#[cfg(unix)]
fs::File::open(path.parent().unwrap())?.sync_all()?;
Ok(())
}
/// Frame offsets never depend on container timestamps (each segment starts at zero).
pub fn frame_offset(frames: u64, fps: u32) -> Duration {
let fps = u64::from(fps.max(1));
Duration::new(frames / fps, ((frames % fps) * 1_000_000_000 / fps) as u32)
}
/// The keyframe triggering `file_changed` is counted already, but is the new
/// segment's first frame. Zero identifies the initial `OutputStarted` segment.
pub fn segment_offset(total_frames: u64, fps: u32) -> Duration {
frame_offset(total_frames.saturating_sub(1), fps)
}
impl Store {
pub fn discover() -> io::Result<Self> {
let root = dirs::data_local_dir()
.ok_or_else(|| io::Error::other("No data directory"))?
.join(if cfg!(windows) { "Clipbin" } else { "clipbin" });
Ok(Self {
root: root.join("sessions"),
})
}
pub fn directory(&self, id: u64) -> PathBuf {
self.root.join(id.to_string())
}
pub fn save(&self, metadata: &Metadata) -> io::Result<()> {
atomic_json(
&self.directory(metadata.session.id).join("session.json"),
metadata,
)
}
pub fn create(&self, app_id: u32, game: String) -> io::Result<Metadata> {
let started = SystemTime::now();
let mut id = started
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
while self.directory(id).exists() {
id += 1;
}
let metadata = Metadata {
session: Session {
id,
game,
started,
length: Duration::ZERO,
active: true,
spans: vec![],
bookmarks: vec![],
},
app_id,
segments: vec![],
};
self.save(&metadata)?;
Ok(metadata)
}
pub fn read(&self) -> io::Result<Vec<Metadata>> {
let entries = match fs::read_dir(&self.root) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e),
};
let mut sessions = vec![];
for entry in entries {
let path = entry?.path().join("session.json");
if path.exists() {
let bytes = fs::read(&path)?;
sessions.push(
serde_json::from_slice(&bytes)
.map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?,
);
}
}
Ok(sessions)
}
/// Active metadata cannot belong to a live recorder during initial startup.
pub fn recover(&self) -> io::Result<()> {
for mut metadata in self.read()? {
if metadata.session.active {
metadata.session.active = false;
for segment in &mut metadata.segments {
segment.complete = true;
}
self.save(&metadata)?;
}
}
Ok(())
}
pub fn library(&self, budget: u64) -> io::Result<Library> {
let mut library = Library::empty(budget);
for metadata in self.read()? {
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
}) {
library.storage.protected_bytes += bytes;
} else {
library.storage.unprotected_bytes += bytes;
}
}
library.sessions.push(metadata.session);
}
library
.sessions
.sort_by_key(|s| std::cmp::Reverse(s.started));
Ok(library)
}
}
impl Metadata {
pub fn update_run(
&mut self,
span_base: usize,
footage_start: Option<Duration>,
footage_end: Duration,
elapsed: Duration,
) {
self.session.spans.truncate(span_base);
if let Some(start) = footage_start {
let previous = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
if start > previous {
self.session.spans.push(Span {
start: previous,
end: start,
kind: SpanKind::Missing,
});
}
if footage_end > start {
self.session.spans.push(Span {
start,
end: footage_end,
kind: SpanKind::Footage,
});
}
} else {
let start = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
if elapsed > start {
self.session.spans.push(Span {
start,
end: elapsed,
kind: SpanKind::Missing,
});
}
}
self.session.length = elapsed.max(footage_end);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offsets_use_frames_without_accumulating_container_rounding() {
assert_eq!(segment_offset(0, 60), Duration::ZERO);
assert_eq!(segment_offset(121, 60), Duration::from_secs(2));
assert_eq!(segment_offset(241, 60), Duration::from_secs(4));
assert_eq!(segment_offset(361, 60), Duration::from_secs(6));
assert_eq!(frame_offset(120, 60), Duration::from_secs(2));
assert_eq!(frame_offset(121, 60), Duration::from_nanos(2_016_666_666));
assert_eq!(frame_offset(216_000, 60), Duration::from_secs(3600));
}
#[test]
fn recovery_preserves_footage_and_a_wall_clock_gap() {
let directory = tempfile::tempdir().unwrap();
let store = Store {
root: directory.path().into(),
};
let mut metadata = store.create(480, "Game".into()).unwrap();
metadata.update_run(
0,
Some(Duration::from_secs(1)),
Duration::from_secs(5),
Duration::from_secs(5),
);
let base = metadata.session.spans.len();
metadata.update_run(base, None, Duration::ZERO, Duration::from_secs(8));
assert_eq!(metadata.session.missing_time(), Duration::from_secs(4));
metadata.update_run(
base,
Some(Duration::from_secs(10)),
Duration::from_secs(12),
Duration::from_secs(12),
);
assert_eq!(metadata.session.missing_time(), Duration::from_secs(6));
assert_eq!(
metadata.session.spans.last().unwrap().kind,
SpanKind::Footage
);
}
#[test]
fn atomic_writes_leave_readers_a_complete_document() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
atomic_json(&path, &Settings::default()).unwrap();
std::thread::scope(|scope| {
let reader = scope.spawn(|| {
for _ in 0..100 {
let bytes = fs::read(&path).unwrap();
serde_json::from_slice::<Settings>(&bytes).unwrap();
}
});
for rate in [30, 60, 120] {
atomic_json(
&path,
&Settings {
frames_per_second: rate,
..Settings::default()
},
)
.unwrap();
}
reader.join().unwrap();
});
}
#[test]
fn round_trip_atomic_replace_and_crash_recovery() {
let root = std::env::temp_dir().join(format!(
"clipbin-store-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let store = Store { root };
let mut metadata = store.create(480, "Test game".into()).unwrap();
metadata.session.length = Duration::from_secs(7);
metadata.segments.push(Segment {
path: PathBuf::from("segment.mkv"),
start: Duration::ZERO,
end: Duration::from_secs(7),
complete: false,
available: true,
frame_start: 0,
frames_per_second: 60,
dropped_frames: 0,
});
fs::write(
store.directory(metadata.session.id).join("segment.mkv"),
[0u8; 13],
)
.unwrap();
store.save(&metadata).unwrap();
assert!(
!store
.directory(metadata.session.id)
.join("session.json.tmp")
.exists()
);
assert_eq!(store.read().unwrap()[0].session, metadata.session);
fs::write(
store.directory(metadata.session.id).join("interrupted.tmp"),
b"{partial",
)
.unwrap();
store.recover().unwrap();
let library = store.library(100).unwrap();
assert!(!library.sessions[0].active);
assert_eq!(library.sessions[0].length, Duration::from_secs(7));
assert_eq!(library.storage.unprotected_bytes, 13);
assert!(store.read().unwrap()[0].segments[0].complete);
fs::remove_dir_all(store.root).unwrap();
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Small Windows process bridge. The rest of the detection code stays safe Rust.
#![allow(unsafe_code)]
use std::{
io,
path::Path,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver},
},
thread::{self, JoinHandle},
};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT},
System::{
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
},
Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
QueryFullProcessImageNameW, WaitForSingleObject,
},
},
};
// The numeric handle can be transferred to the waiting thread; Windows process
// handles are valid across threads. Ownership still has exactly one Drop.
struct Handle(usize);
impl Handle {
fn raw(&self) -> HANDLE {
self.0 as HANDLE
}
fn process(pid: u32) -> io::Result<Self> {
// SAFETY: valid access flags and PID; ownership of the result is taken here.
let raw = unsafe {
OpenProcess(
PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
0,
pid,
)
};
if raw.is_null() {
Err(io::Error::last_os_error())
} else {
Ok(Self(raw as usize))
}
}
fn path(&self) -> io::Result<String> {
let mut buffer = vec![0u16; 32768];
let mut length = buffer.len() as u32;
// SAFETY: the handle is owned, and the writable buffer has the stated capacity.
if unsafe { QueryFullProcessImageNameW(self.raw(), 0, buffer.as_mut_ptr(), &mut length) }
== 0
{
return Err(io::Error::last_os_error());
}
Ok(String::from_utf16_lossy(&buffer[..length as usize]))
}
}
impl Drop for Handle {
fn drop(&mut self) {
// SAFETY: Handle owns a successful API result and closes it exactly once.
unsafe {
CloseHandle(self.raw());
}
}
}
pub fn processes_in(directory: &Path) -> io::Result<Vec<(u32, String)>> {
// SAFETY: flags request process enumeration; no caller pointers are involved.
let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if raw == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
let snapshot = Handle(raw as usize);
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
let prefix = format!(
"{}\\",
directory.to_string_lossy().trim_end_matches(['\\', '/'])
)
.to_lowercase();
let mut result = vec![];
// SAFETY: snapshot remains live and entry is correctly sized and writable.
let mut found = unsafe { Process32FirstW(snapshot.raw(), &mut entry) } != 0;
while found {
// Access-denied system processes and processes exiting during enumeration
// are expected and cannot belong to the selected installation.
if let Ok(process) = Handle::process(entry.th32ProcessID)
&& let Ok(path) = process.path()
&& path.to_lowercase().starts_with(&prefix)
&& let Some(executable) = Path::new(&path).file_name().and_then(|s| s.to_str())
{
result.push((entry.th32ProcessID, executable.into()));
}
// SAFETY: as for Process32FirstW; snapshot and entry are still live.
found = unsafe { Process32NextW(snapshot.raw(), &mut entry) } != 0;
}
result.sort_by_key(|(pid, _)| *pid);
Ok(result)
}
pub struct ExitWatch {
events: Receiver<io::Result<()>>,
cancel: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl ExitWatch {
pub fn open(pid: u32, executable: &str) -> io::Result<Self> {
let handle = Handle::process(pid)?;
if !Path::new(&handle.path()?)
.file_name()
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(executable))
{
return Err(io::Error::other("Game exited during discovery"));
}
let (sender, events) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let cancelled = cancel.clone();
let thread = thread::Builder::new()
.name("clipbin-game-exit".into())
.spawn(move || {
while !cancelled.load(Ordering::Relaxed) {
// SAFETY: the thread owns a live process handle with SYNCHRONIZE
// access. A finite wait lets Drop cancel without leaking a waiter.
let result = unsafe { WaitForSingleObject(handle.raw(), 250) };
match result {
WAIT_OBJECT_0 => {
let _ = sender.send(Ok(()));
break;
}
WAIT_TIMEOUT => {}
_ => {
let _ = sender.send(Err(io::Error::last_os_error()));
break;
}
}
}
})?;
Ok(Self {
events,
cancel,
thread: Some(thread),
})
}
pub fn exited(&self) -> io::Result<bool> {
match self.events.try_recv() {
Ok(result) => result.map(|()| true),
Err(mpsc::TryRecvError::Empty) => Ok(false),
Err(mpsc::TryRecvError::Disconnected) => Ok(true),
}
}
}
impl Drop for ExitWatch {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
+1
View File
@@ -10,6 +10,7 @@ publish.workspace = true
[dependencies]
clipbin-core.workspace = true
clipbin-obs.workspace = true
gpui.workspace = true
[lints]
+56
View File
@@ -0,0 +1,56 @@
//! Makes development builds run against the OBS runtime in target/obs-runtime.
//!
//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on
//! Windows the OBS DLLs must sit beside the executable too, so they are linked
//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps
//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged
//! builds place the executable in the runtime's own bin directory instead (see
//! clipbin_obs::RuntimeLayout).
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
if target_os == "linux" {
// Packaged layout: <root>/bin/<exe> loads <root>/lib/libobs.so.
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib");
}
let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else {
return;
};
let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}"));
// OUT_DIR is <profile>/build/<package>-<hash>/out.
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf();
let (bin_dir, lib_dir) = match target_os.as_str() {
"windows" => (runtime.join("bin").join("64bit"), None),
_ => (runtime.join("bin"), Some(runtime.join("lib"))),
};
if let Some(lib_dir) = lib_dir {
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display());
}
let Ok(entries) = fs::read_dir(&bin_dir) else {
println!(
"cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it",
runtime.display()
);
return;
};
println!("cargo:rerun-if-changed={}", bin_dir.display());
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let destination = profile_dir.join(entry.file_name());
let _ = fs::remove_file(&destination);
#[cfg(unix)]
std::os::unix::fs::symlink(&path, &destination).unwrap();
#[cfg(not(unix))]
fs::copy(&path, &destination).unwrap();
}
}
+47 -4
View File
@@ -68,6 +68,7 @@ pub struct ClipbinApp {
/// Where the timeline was last laid out, to turn pointer positions into times.
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
pub fields: SettingsFields,
pub service: crate::recorder_service::Client,
}
/// The typed settings and the fields that edit them.
@@ -106,6 +107,7 @@ impl SettingsFields {
window,
move |app, _, Committed(text), window, cx| {
if apply(&mut app.settings, text) {
app.save_settings();
app.fields.refresh(&app.settings, window, cx);
cx.notify();
}
@@ -185,8 +187,12 @@ impl SettingsFields {
}
impl ClipbinApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = Settings::default();
pub fn new(
window: &mut Window,
cx: &mut Context<Self>,
settings: Settings,
service: crate::recorder_service::Client,
) -> Self {
let fields = SettingsFields::new(&settings, window, cx);
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
@@ -232,6 +238,7 @@ impl ClipbinApp {
drag: None,
timeline_bounds: Rc::default(),
fields,
service,
};
match std::env::var("CLIPBIN_ROUTE").as_deref() {
Ok("clips") => app.route = Route::Clips,
@@ -248,9 +255,45 @@ impl ClipbinApp {
}
_ => {}
}
if !sample {
cx.spawn(async move |entity, cx| {
loop {
gpui::Timer::after(Duration::from_millis(250)).await;
if entity
.update(cx, |app, cx| {
let snapshot = app.service.snapshot();
if app.recorder.status != snapshot.status
|| app.recorder.game != snapshot.game
|| app.library != snapshot.library
|| app.settings.portal_restore_token != snapshot.restore_token
{
app.recorder.status = snapshot.status;
app.recorder.game = snapshot.game;
if let Route::Session(id) = app.route
&& let Some(session) = snapshot.library.session(id)
{
app.editor.view.set_length(session.length);
}
app.library = snapshot.library;
app.settings.portal_restore_token = snapshot.restore_token;
cx.notify();
}
})
.is_err()
{
break;
}
}
})
.detach();
}
app
}
pub fn save_settings(&self) {
self.service.settings(self.settings.clone());
}
pub fn navigate(&mut self, route: Route, cx: &mut Context<Self>) {
self.notice = None;
match route {
@@ -530,8 +573,8 @@ impl ClipbinApp {
Some("Choose capture source"),
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.notice =
Some("Choosing a source arrives with the recorder integration.".into());
this.settings.portal_restore_token = None;
this.service.choose_source();
cx.notify();
})),
)
+34 -10
View File
@@ -6,6 +6,7 @@ use gpui::{
mod app;
mod assets;
mod components;
mod recorder_service;
mod screens;
mod text_field;
mod theme;
@@ -14,24 +15,39 @@ mod timeline_view;
actions!(clipbin, [Quit]);
fn main() {
let (settings, initial_error) = match clipbin_core::store::load_settings() {
Ok(settings) => (settings, None),
Err(error) => (
clipbin_core::Settings::default(),
Some(format!("Could not load settings: {error}")),
),
};
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
let interface_service = service.clone();
Application::new()
.with_assets(assets::Assets)
.run(|cx: &mut App| {
if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}");
}
cx.on_action(|_: &Quit, cx| cx.quit());
let quitter = interface_service.clone();
cx.on_action(move |_: &Quit, cx| {
quitter.quit();
let service = quitter.clone();
cx.spawn(async move |cx| {
loop {
gpui::Timer::after(std::time::Duration::from_millis(100)).await;
if service.snapshot().exited {
let _ = cx.update(|cx| cx.quit());
break;
}
}
})
.detach();
});
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
text_field::bind_keys(cx);
// This shell has no background recorder yet.
cx.on_window_closed(|cx| {
if cx.windows().is_empty() {
cx.quit();
}
})
.detach();
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
let result = cx.open_window(
WindowOptions {
@@ -44,7 +60,9 @@ fn main() {
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
|window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx)),
|window, cx| {
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, interface_service))
},
);
if let Err(error) = result {
@@ -55,4 +73,10 @@ fn main() {
cx.activate(true);
});
// GPUI 0.2.2 ends its Linux event loop after the last window closes.
// Finish the current session after closing; stop discovery and exit when
// its game ends. Reopening the window needs the future tray integration.
service.window_closed();
let _ = worker.join();
drop(service);
}
@@ -0,0 +1,565 @@
//! One worker owns OBS; the interface sends commands and reads cheap snapshots.
use clipbin_core::{
Library, RecordingStatus, Settings,
games::{self, ExitWatch, Game},
library::{Span, SpanKind},
store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
};
use clipbin_obs::{
Engine, RecorderEvent, Recording, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
capture,
};
use std::{
sync::{
Arc, Mutex,
mpsc::{self, Receiver, Sender},
},
thread,
time::{Duration, Instant},
};
#[derive(Clone)]
pub struct Snapshot {
pub status: RecordingStatus,
pub game: Option<String>,
pub library: Library,
pub restore_token: Option<String>,
pub exited: bool,
}
#[derive(Clone)]
pub struct Client {
commands: Sender<Command>,
snapshot: Arc<Mutex<Snapshot>>,
}
enum Command {
Settings(Settings),
ChooseSource,
WindowClosed,
Quit,
}
impl Client {
pub fn start(
settings: Settings,
initial_error: Option<String>,
) -> (Self, thread::JoinHandle<()>) {
let (commands, receiver) = mpsc::channel();
let snapshot = Arc::new(Mutex::new(Snapshot {
status: initial_error.map_or(RecordingStatus::Idle, |reason| RecordingStatus::Failed {
reason,
}),
game: None,
library: Library::empty(settings.unprotected_session_limit_bytes),
restore_token: settings.portal_restore_token.clone(),
exited: false,
}));
let state = snapshot.clone();
let worker = thread::Builder::new()
.name("clipbin-recorder".into())
.spawn(move || {
if let Err(error) = worker(settings, receiver, &state) {
state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
}
})
.expect("start recorder worker");
let client = Self { commands, snapshot };
(client, worker)
}
pub fn snapshot(&self) -> Snapshot {
self.snapshot.lock().unwrap().clone()
}
pub fn settings(&self, settings: Settings) {
let _ = self.commands.send(Command::Settings(settings));
}
pub fn choose_source(&self) {
let _ = self.commands.send(Command::ChooseSource);
}
pub fn window_closed(&self) {
let _ = self.commands.send(Command::WindowClosed);
}
pub fn quit(&self) {
if self.commands.send(Command::Quit).is_err() {
self.snapshot.lock().unwrap().exited = true;
}
}
}
struct Active {
game: Game,
watch: ExitWatch,
metadata: Metadata,
started: Instant,
recording: Option<Recording>,
tracker: StatusTracker,
run: u32,
run_base: Option<Duration>,
span_base: usize,
last_frames: u64,
last_dropped: u64,
fps: u32,
stopping: bool,
ending: bool,
retry_at: Instant,
retries: u32,
}
impl Active {
fn start_run(
&mut self,
engine: &Engine,
settings: &Settings,
store: &Store,
) -> Result<(), String> {
let encoder = engine
.hardware_encoders(settings.codec)
.into_iter()
.next()
.ok_or_else(|| format!("No hardware {} encoder", settings.codec))?;
#[cfg(windows)]
let target = capture::CaptureTarget::Game {
window: capture::WindowMatch {
obs_window: format!("::{}", self.game.executable),
restore_token: None,
},
};
#[cfg(not(windows))]
let target = capture::CaptureTarget::Monitor {
monitor: capture::MonitorMatch {
obs_monitor: String::new(),
restore_token: settings.portal_restore_token.clone(),
},
};
#[cfg(windows)]
let video_source = target.video_source();
#[cfg(not(windows))]
let video_source = target.video_source(
capture::LinuxSession::detect().ok_or("No Wayland or X11 display for capture")?,
);
self.fps = settings.frames_per_second;
self.run += 1;
self.run_base = None;
self.span_base = self.metadata.session.spans.len();
self.last_frames = 0;
self.last_dropped = 0;
self.tracker = StatusTracker::new(
Instant::now(),
StatusTracker::DEFAULT_MIN_FREE_BYTES,
Duration::from_secs(2),
);
self.recording = Some(
engine
.start_recording(RecordingConfig {
frames_per_second: Some(settings.frames_per_second),
video_source: Some(video_source),
system_audio: settings
.system_audio_enabled
.then(capture::system_audio_source),
microphone: settings.microphone_enabled.then(capture::microphone_source),
resolution: settings.resolution,
video_encoder: encoder,
video_bitrate_kbps: 12_000,
keyframe_interval_seconds: 1,
segment_seconds: 2,
segment_dir: store
.directory(self.metadata.session.id)
.join(format!("run-{:03}", self.run)),
})
.map_err(|e| e.to_string())?,
);
self.stopping = false;
Ok(())
}
fn update_timeline(&mut self) {
let end = self.run_base.map_or(Duration::ZERO, |base| {
base + frame_offset(self.last_frames, self.fps)
});
self.metadata
.update_run(self.span_base, self.run_base, end, self.started.elapsed());
if let Some(segment) = self.metadata.segments.last_mut()
&& !segment.complete
{
segment.end = end.max(segment.start);
}
}
fn finish_run(&mut self, store: &Store) -> Result<(), String> {
let mut empty_tail = false;
if let Some(segment) = self.metadata.segments.last_mut()
&& !segment.complete
{
let path = store
.directory(self.metadata.session.id)
.join(&segment.path);
if !clipbin_core::segments::has_cluster(&path).map_err(|e| e.to_string())? {
eprintln!(
"Warning: {} has no media cluster; its tail is Missing",
path.display()
);
segment.available = false;
segment.end = segment.start;
self.last_frames = segment.frame_start;
match std::fs::remove_file(&path) {
Ok(()) => empty_tail = true,
Err(error) => {
eprintln!("Could not remove empty segment: {error}; marked unavailable")
}
}
}
}
if empty_tail {
self.metadata.segments.pop();
}
self.update_timeline();
if let Some(segment) = self.metadata.segments.last_mut()
&& !segment.complete
{
segment.complete = true;
segment.dropped_frames = self.last_dropped;
if self.last_dropped > 0 {
eprintln!(
"Warning: {} frames dropped in run {}; segment offsets may drift",
self.last_dropped, self.run
);
}
}
self.span_base = self.metadata.session.spans.len();
self.run_base = None;
self.recording = None;
self.stopping = false;
self.retry_at = Instant::now() + Duration::from_secs(5);
Ok(())
}
fn finish_session(&mut self) {
self.metadata.session.length = self.started.elapsed().max(self.metadata.session.length);
let end = self
.metadata
.session
.spans
.last()
.map_or(Duration::ZERO, |s| s.end);
if self.metadata.session.length > end {
self.metadata.session.spans.push(Span {
start: end,
end: self.metadata.session.length,
kind: SpanKind::Missing,
});
}
self.metadata.session.active = false;
}
}
fn fail(snapshot: &Arc<Mutex<Snapshot>>, reason: impl ToString) {
snapshot.lock().unwrap().status = RecordingStatus::Failed {
reason: reason.to_string(),
};
}
fn worker(
mut settings: Settings,
commands: Receiver<Command>,
snapshot: &Arc<Mutex<Snapshot>>,
) -> Result<(), String> {
let store = Store::discover().map_err(|e| e.to_string())?;
store.recover().map_err(|e| e.to_string())?;
snapshot.lock().unwrap().library = store
.library(settings.unprotected_session_limit_bytes)
.map_err(|e| e.to_string())?;
// An absent runtime is an interface-visible error; keep accepting commands.
let engine = RuntimeLayout::discover()
.map_err(|e| e.to_string())
.and_then(|layout| {
Engine::start(&layout, settings.frames_per_second).map_err(|e| e.to_string())
});
let engine = match engine {
Ok(engine) => Some(engine),
Err(e) => {
fail(snapshot, e);
None
}
};
let mut active: Option<Active> = None;
let mut quitting = false;
let mut finish_after_session = false;
let mut next_scan = Instant::now();
let mut next_poll = Instant::now();
let mut next_save = Instant::now();
let mut previous_status = None;
loop {
match commands.recv_timeout(Duration::from_millis(200)) {
Ok(Command::WindowClosed) => {
finish_after_session = true;
eprintln!("Window closed: finishing the current game session, then exiting");
}
Ok(Command::Quit) => {
quitting = true;
if let Some(a) = &mut active {
a.ending = true;
if !a.stopping
&& let Some(r) = &a.recording
{
r.stop();
a.stopping = true;
}
}
}
Ok(Command::Settings(new)) => {
settings = new;
snapshot.lock().unwrap().library.storage.budget_bytes =
settings.unprotected_session_limit_bytes;
if let Err(e) = store::save_settings(&settings) {
fail(snapshot, format!("Could not save settings: {e}"));
}
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
}
Ok(Command::ChooseSource) => {
settings.portal_restore_token = None;
if let Err(e) = store::save_settings(&settings) {
fail(snapshot, e);
}
snapshot.lock().unwrap().restore_token = None;
if let Some(a) = &mut active {
a.retries = 0;
a.retry_at = Instant::now();
if !a.stopping
&& let Some(r) = &a.recording
{
r.stop();
a.stopping = true;
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if (quitting || finish_after_session) && active.is_none() {
break;
}
let now = Instant::now();
if active.is_none() && !quitting && now >= next_scan {
next_scan = now + Duration::from_secs(1);
match games::discover() {
Ok(games) => {
if let Some(game) = games.into_iter().next()
&& let Some(engine) = &engine
{
match ExitWatch::open(&game).and_then(|watch| {
store
.create(game.app_id, game.name.clone())
.map(|metadata| (watch, metadata))
}) {
Ok((watch, metadata)) => {
let mut a = Active {
game,
watch,
metadata,
started: now,
recording: None,
tracker: StatusTracker::new(now, 0, Duration::from_secs(2)),
run: 0,
run_base: None,
span_base: 0,
last_frames: 0,
last_dropped: 0,
fps: settings.frames_per_second,
stopping: false,
ending: false,
retry_at: now + Duration::from_secs(5),
retries: 0,
};
snapshot.lock().unwrap().game = Some(a.game.name.clone());
match a.start_run(engine, &settings, &store) {
Ok(()) => {
let mut state = snapshot.lock().unwrap();
if !matches!(state.status, RecordingStatus::Failed { .. }) {
state.status = RecordingStatus::Starting;
}
}
Err(e) => fail(snapshot, e),
}
active = Some(a);
}
Err(e) => fail(snapshot, e),
}
}
}
Err(e) => fail(snapshot, e),
}
}
if let Some(a) = &mut active {
// Process exit has priority over health: closing a game is normal.
match a.watch.exited() {
Ok(true) => a.ending = true,
Err(e) => {
fail(snapshot, format!("Could not watch game exit: {e}"));
a.ending = true;
}
Ok(false) => {}
}
if a.ending
&& !a.stopping
&& let Some(r) = &a.recording
{
r.stop();
a.stopping = true;
}
if !a.ending
&& a.recording.is_none()
&& a.retries < 3
&& now >= a.retry_at
&& let Some(engine) = &engine
{
a.retries += 1;
a.retry_at = now + Duration::from_secs(5);
if let Err(e) = a.start_run(engine, &settings, &store) {
fail(snapshot, e);
}
}
let mut stopped = false;
if let Some(r) = &a.recording {
if now >= next_poll {
next_poll = now + Duration::from_secs(1);
match r.poll() {
Ok(health) => {
a.last_frames = health.total_frames;
a.last_dropped = health.dropped_frames;
if health.output_active && a.run_base.is_none() {
a.run_base = Some(
a.started
.elapsed()
.saturating_sub(frame_offset(health.total_frames, a.fps)),
);
}
if !a.ending && !a.stopping {
let status = a.tracker.sample(&health, now).clone();
if status == RecordingStatus::Recording {
a.retries = 0;
}
// Keep a failure visible throughout recovery until footage is proven.
if status == RecordingStatus::Recording
|| !matches!(
snapshot.lock().unwrap().status,
RecordingStatus::Failed { .. }
)
{
snapshot.lock().unwrap().status = status.clone();
}
if matches!(status, RecordingStatus::Failed { .. }) {
r.stop();
a.stopping = true;
}
}
if let Some(token) = r.restore_token()
&& settings.portal_restore_token.as_ref() != Some(&token)
{
settings.portal_restore_token = Some(token.clone());
snapshot.lock().unwrap().restore_token = Some(token);
if let Err(e) = store::save_settings(&settings) {
fail(snapshot, e);
}
}
}
Err(e) => {
fail(snapshot, e);
if !a.stopping {
r.stop();
a.stopping = true;
}
}
}
}
for event in r.events().try_iter() {
match event {
RecorderEvent::SegmentStarted {
path,
total_frames,
dropped_frames,
} => {
let base = *a.run_base.get_or_insert_with(|| {
a.started
.elapsed()
.saturating_sub(frame_offset(a.last_frames, a.fps))
});
let offset = base + segment_offset(total_frames, a.fps);
if let Some(previous) = a.metadata.segments.last_mut()
&& !previous.complete
{
previous.end = offset;
previous.complete = true;
previous.dropped_frames = dropped_frames;
if dropped_frames > 0 {
eprintln!(
"Warning: {dropped_frames} frames dropped in run {}; segment offsets may drift",
a.run
);
}
}
let path = std::path::PathBuf::from(path);
let relative = path
.strip_prefix(store.directory(a.metadata.session.id))
.map_err(|e| e.to_string())?
.to_path_buf();
a.metadata.segments.push(Segment {
path: relative,
start: offset,
end: offset,
complete: false,
available: true,
frame_start: total_frames.saturating_sub(1),
frames_per_second: a.fps,
dropped_frames: 0,
});
}
RecorderEvent::OutputStopped { code, last_error } => {
if !a.stopping && !a.ending {
fail(snapshot, "The recording output stopped unexpectedly");
}
if code != 0 {
snapshot.lock().unwrap().status = a
.tracker
.stopped(StopReason::from_code(code), last_error.as_deref())
.clone();
}
// Read final cumulative frame count after output finalisation.
if let Ok(health) = r.poll() {
a.last_frames = health.total_frames;
a.last_dropped = health.dropped_frames;
}
stopped = true;
}
_ => {}
}
}
}
if stopped {
a.finish_run(&store)?;
}
a.update_timeline();
if now >= next_save || stopped || a.ending {
next_save = now + Duration::from_secs(1);
if a.ending && a.recording.is_none() {
a.finish_session();
store.save(&a.metadata).map_err(|e| e.to_string())?;
let mut state = snapshot.lock().unwrap();
if !matches!(state.status, RecordingStatus::Failed { .. }) {
state.status = RecordingStatus::Idle;
}
drop(state);
snapshot.lock().unwrap().game = None;
active = None;
} 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,
Err(e) => fail(snapshot, e),
}
}
}
let status = snapshot.lock().unwrap().status.clone();
if previous_status.as_ref() != Some(&status) {
eprintln!("recorder_status={status:?}");
previous_status = Some(status);
}
}
drop(active);
drop(engine);
eprintln!("Recorder stopped; exiting");
snapshot.lock().unwrap().exited = true;
Ok(())
}
@@ -77,7 +77,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.max_w(px(820.))
.child(page_header(
"Settings",
"Changes apply now. Saving them between launches comes with settings storage.",
"Settings are saved automatically. Recording changes apply to the next capture run.",
Vec::new(),
))
.child(
@@ -259,6 +259,7 @@ fn segmented(
.child(*label)
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
update(&mut app.settings);
app.save_settings();
cx.notify();
}))
}),
@@ -289,6 +290,7 @@ fn toggle(
.child(div().size_4().rounded_full().bg(theme::foreground()))
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
update(&mut app.settings);
app.save_settings();
cx.notify();
}))
}
@@ -21,6 +21,15 @@ impl TimelineView {
}
}
/// New footage extends a full view without moving a user's zoomed range.
pub fn set_length(&mut self, length: Duration) {
let full = !self.is_zoomed();
self.length = length;
if full {
self.span = length.max(Duration::from_secs(1));
}
}
pub fn start(&self) -> Duration {
self.start
}
@@ -79,6 +88,17 @@ mod tests {
const HOUR: Duration = Duration::from_secs(3600);
#[test]
fn new_footage_extends_the_full_view_and_preserves_a_zoomed_view() {
let mut view = TimelineView::full(Duration::from_secs(20));
view.set_length(Duration::from_secs(40));
assert_eq!(view.end(), Duration::from_secs(40));
view.zoom_by(0.5, 0.5);
let range = (view.start(), view.end());
view.set_length(Duration::from_secs(60));
assert_eq!((view.start(), view.end()), range);
assert_eq!(view.time_at(1.0), range.1);
}
#[test]
fn zooming_keeps_the_time_under_the_pointer() {
let mut view = TimelineView::full(HOUR);
+18 -4
View File
@@ -521,11 +521,15 @@ struct SignalContext {
}
impl SignalContext {
fn segment_started(&self, path: String) {
fn segment_started(&self, path: String, total_frames: u64, dropped_frames: u64) {
if let Ok(mut current) = self.current_segment.lock() {
*current = Some(path.clone());
}
self.send(RecorderEvent::SegmentStarted { path });
self.send(RecorderEvent::SegmentStarted {
path,
total_frames,
dropped_frames,
});
}
fn send(&self, event: RecorderEvent) {
@@ -549,7 +553,7 @@ unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) {
// outlives the connection.
let context = unsafe { &*(data as *const SignalContext) };
context.send(RecorderEvent::OutputStarted);
context.segment_started(context.first_segment.clone());
context.segment_started(context.first_segment.clone(), 0, 0);
}
unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) {
@@ -588,7 +592,17 @@ unsafe extern "C" fn on_file_changed(data: *mut c_void, calldata: *mut sys::call
let settings = Data(unsafe { sys::obs_output_get_settings(context.output) });
settings.set("format", &SettingValue::String(segment_stem(index + 1)));
}
context.segment_started(path);
// SAFETY: the output outlives its signal connections.
let total_frames = unsafe { sys::obs_output_get_total_frames(context.output) };
// The split-triggering keyframe is already counted here, but belongs to
// the new file. Consumers subtract one for placement; retain the raw count.
// SAFETY: as above, the output outlives this callback.
let dropped_frames = unsafe { sys::obs_output_get_frames_dropped(context.output) };
context.segment_started(
path,
total_frames.max(0) as u64,
dropped_frames.max(0) as u64,
);
}
pub(crate) fn segment_index(path: &str) -> Option<u64> {
+8 -2
View File
@@ -112,7 +112,7 @@ impl Engine {
microphone: config.microphone,
};
let output = OutputSpec {
frames_per_second: self.frames_per_second,
frames_per_second: config.frames_per_second.unwrap_or(self.frames_per_second),
resolution: config.resolution,
video_encoder: config.video_encoder,
video_encoder_settings: vec![
@@ -161,6 +161,8 @@ fn forward_slashes(path: &Path) -> String {
}
pub struct RecordingConfig {
/// Override the engine startup rate for this recording.
pub frames_per_second: Option<u32>,
pub video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
@@ -282,7 +284,11 @@ pub enum RecorderEvent {
/// OBS reports the output as started. Not yet proof that footage is written.
OutputStarted,
/// The muxer opened a new segment file; the previous one is complete.
SegmentStarted { path: String },
SegmentStarted {
path: String,
total_frames: u64,
dropped_frames: u64,
},
OutputStopped {
code: i64,
last_error: Option<String>,
+3 -1
View File
@@ -221,6 +221,7 @@ fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
let dir = options.out.join(format!("verify-{key}-{encoder}"));
let _ = std::fs::remove_dir_all(&dir);
let config = RecordingConfig {
frames_per_second: None,
video_source: None,
system_audio: None,
microphone: None,
@@ -319,6 +320,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
let _ = std::fs::remove_dir_all(&dir);
println!("segment_dir={}", dir.display());
let config = RecordingConfig {
frames_per_second: None,
video_source: Some(video_source),
system_audio: options.audio.then(capture::system_audio_source),
microphone: options.mic.then(capture::microphone_source),
@@ -377,7 +379,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
println!("t={} recording_size={width}x{height}", elapsed())
}
Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()),
Ok(RecorderEvent::SegmentStarted { path }) => {
Ok(RecorderEvent::SegmentStarted { path, .. }) => {
println!("t={} segment_started={path}", elapsed())
}
Ok(RecorderEvent::OutputStopped { code, last_error }) => {