feat: export clips, protect bookmarks and clean session storage
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -15,3 +15,8 @@ pub mod store;
|
||||
mod windows_process;
|
||||
|
||||
pub mod segments;
|
||||
|
||||
pub mod cleanup;
|
||||
pub mod clips;
|
||||
|
||||
pub mod hotkeys;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -10,6 +10,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clipbin-core.workspace = true
|
||||
clipbin-media.workspace = true
|
||||
clipbin-obs.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -67,3 +67,5 @@ mod tests {
|
||||
println!("{probe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod export;
|
||||
|
||||
Reference in New Issue
Block a user