Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions crates/project/examples/keyboard-incremental-encoding-benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use cap_project::{KeyPressEvent, KeyboardEvents, KeyboardEventsEncoder};
use std::{error::Error, hint::black_box, time::Instant};

const FLUSHES: usize = 720;
const PRESSES_PER_FLUSH: usize = 100;

fn press(index: usize) -> KeyPressEvent {
KeyPressEvent {
key: ["a", "文字🖱", "Space", "LControl"][index % 4].into(),
key_code: "KeyA".into(),
time_ms: index as f64 * 50.25,
down: index % 2 == 0,
}
}

fn batch(flush: usize) -> Vec<KeyPressEvent> {
(flush * PRESSES_PER_FLUSH..(flush + 1) * PRESSES_PER_FLUSH)
.map(press)
.collect()
}

fn whole(batches: &[Vec<KeyPressEvent>]) -> Result<(f64, f64, Vec<u8>), Box<dyn Error>> {
let mut events = KeyboardEvents::default();
let mut total = 0.0;
let mut last_flush = 0.0;
let mut bytes = Vec::new();
for batch in batches {
let started = Instant::now();
events.presses.extend(batch.iter().cloned());
bytes = black_box(events.to_bytes()?);
last_flush = started.elapsed().as_secs_f64() * 1000.0;
total += last_flush;
}
Ok((total, last_flush, bytes))
}

fn incremental(batches: &[Vec<KeyPressEvent>]) -> Result<(f64, f64, Vec<u8>), Box<dyn Error>> {
let mut encoder = KeyboardEventsEncoder::default();
let mut total = 0.0;
let mut last_flush = 0.0;
for batch in batches {
let started = Instant::now();
for press in batch {
encoder.push(press)?;
}
black_box(encoder.header()?);
black_box(encoder.presses());
last_flush = started.elapsed().as_secs_f64() * 1000.0;
total += last_flush;
}
let mut bytes = encoder.header()?;
bytes.extend_from_slice(encoder.presses());
Ok((total, last_flush, bytes))
}

fn main() -> Result<(), Box<dyn Error>> {
let mut arguments = std::env::args().skip(1);
let samples: usize = arguments.next().unwrap_or_else(|| "5".into()).parse()?;
if samples == 0 || arguments.next().is_some() {
return Err("expected one positive sample count".into());
}
let batches: Vec<_> = (0..FLUSHES).map(batch).collect();
let expected = KeyboardEvents {
presses: batches.iter().flatten().cloned().collect(),
}
.to_bytes()?;
println!("sample,mode,events,flushes,total_ms,last_flush_ms,bytes");
for sample in 0..samples {
let mut modes = [
("whole", whole as fn(&[Vec<KeyPressEvent>]) -> _),
("incremental", incremental),
];
if sample % 2 == 1 {
modes.reverse();
}
for (mode, run) in modes {
let (total, last_flush, bytes) = run(&batches)?;
assert_eq!(bytes, expected);
println!(
"{sample},{mode},{},{FLUSHES},{total:.3},{last_flush:.4},{}",
FLUSHES * PRESSES_PER_FLUSH,
bytes.len()
);
}
}
Ok(())
}
93 changes: 93 additions & 0 deletions crates/project/src/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,44 @@ impl KeyboardEvents {
}
}

/// Builds the bytes of [`KeyboardEvents::to_bytes`] one press at a time. With
/// bincode's standard config a struct is its fields back to back and a `Vec`
/// is a varint length followed by its elements, so the encoded presses can be
/// kept and only the length prefix rebuilt for each write.
#[derive(Default)]
pub struct KeyboardEventsEncoder {
presses: Vec<u8>,
count: usize,
}

impl KeyboardEventsEncoder {
pub fn push(&mut self, press: &KeyPressEvent) -> Result<(), String> {
let start = self.presses.len();
bincode::serde::encode_into_std_write(
press,
&mut self.presses,
bincode::config::standard(),
)
.map_err(|e| {
self.presses.truncate(start);
format!("Failed to serialize keyboard event: {e}")
})?;
self.count += 1;
Ok(())
}

pub fn header(&self) -> Result<Vec<u8>, String> {
let mut header = KEYBOARD_EVENTS_MAGIC.to_vec();
bincode::encode_into_std_write(self.count, &mut header, bincode::config::standard())
.map_err(|e| format!("Failed to serialize keyboard events: {e}"))?;
Ok(header)
}

pub fn presses(&self) -> &[u8] {
&self.presses
}
}

const MODIFIER_KEYS: &[&str] = &[
"LShift", "RShift", "LControl", "RControl", "LAlt", "RAlt", "LMeta", "RMeta", "Meta", "Command",
];
Expand Down Expand Up @@ -1252,6 +1290,61 @@ mod tests {
}
}

#[test]
fn incremental_encoder_matches_whole_encoding_across_varint_widths() {
let mut seed = 0x9e37_79b9_7f4a_7c15_u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let keys = ["", "a", "文字🖱", "LControl", &"x".repeat(300)];
let press = |index: usize, random: u64| KeyPressEvent {
key: keys[random as usize % keys.len()].into(),
key_code: keys[(random >> 8) as usize % keys.len()].into(),
time_ms: match random % 7 {
0 => f64::NAN,
1 => -0.0,
2 => f64::INFINITY,
_ => index as f64 * 16.5,
},
down: random & 1 == 0,
};
let check = |encoder: &KeyboardEventsEncoder, events: &KeyboardEvents| {
let mut bytes = encoder.header().unwrap();
bytes.extend_from_slice(encoder.presses());
assert_eq!(
bytes,
events.to_bytes().unwrap(),
"{} events",
events.presses.len()
);
};
let mut encoder = KeyboardEventsEncoder::default();
let mut events = KeyboardEvents::default();
check(&encoder, &events);
for target in [0, 1, 249, 250, 251, 252, 65_535, 65_536, 65_537, 70_000] {
while events.presses.len() < target {
let batch = (next() % 600) as usize;
let batch = batch.min(target - events.presses.len());
for _ in 0..batch {
let event = press(events.presses.len(), next());
encoder.push(&event).unwrap();
events.presses.push(event);
}
check(&encoder, &events);
}
check(&encoder, &events);
}
let decoded = KeyboardEvents::from_bytes(
&[encoder.header().unwrap(), encoder.presses().to_vec()].concat(),
Path::new("keyboard.bin"),
)
.unwrap();
assert_eq!(decoded.presses.len(), events.presses.len());
}

#[test]
fn roundtrips_binary_keyboard_events() {
let events = KeyboardEvents {
Expand Down
28 changes: 16 additions & 12 deletions crates/recording/src/cursor.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::RecordingStartGate;
use cap_cursor_capture::CursorCropBounds;
use cap_cursor_info::CursorShape;
use cap_project::{CursorClickEvent, CursorMoveEvent, KeyPressEvent, KeyboardEvents, XY};
use cap_project::{CursorClickEvent, CursorMoveEvent, KeyPressEvent, KeyboardEventsEncoder, XY};
use cap_timestamp::Timestamps;
use futures::{FutureExt, future::Shared};
use std::{
Expand Down Expand Up @@ -225,13 +225,14 @@ impl FlushedEvents {

/// The incremental cursor and keyboard files, built up from deltas. Moves and
/// clicks are kept serialized, so a flush serializes only its new events and
/// writes the same compact JSON `serde_json::to_vec` gives for `CursorEvents`.
/// A file is rewritten only when it has new events or its last write failed.
/// writes the same compact JSON `serde_json::to_vec` gives for `CursorEvents`;
/// key presses likewise give the bytes of `KeyboardEvents::to_bytes`. A file
/// is rewritten only when it has new events or its last write failed.
struct IncrementalFiles {
outputs: IncrementalCaptureOutputs,
clicks_json: Vec<u8>,
moves_json: Vec<u8>,
keyboard: KeyboardEvents,
keyboard: KeyboardEventsEncoder,
cursor_pending: bool,
keyboard_pending: bool,
}
Expand All @@ -242,7 +243,7 @@ impl IncrementalFiles {
outputs,
clicks_json: Vec::new(),
moves_json: Vec::new(),
keyboard: KeyboardEvents::default(),
keyboard: KeyboardEventsEncoder::default(),
cursor_pending: true,
keyboard_pending: true,
}
Expand All @@ -260,7 +261,11 @@ impl IncrementalFiles {
}
if self.outputs.keyboard.is_some() {
self.keyboard_pending |= !delta.keyboard_presses.is_empty();
self.keyboard.presses.extend(delta.keyboard_presses);
for press in &delta.keyboard_presses {
if let Err(e) = self.keyboard.push(press) {
tracing::error!("{e}");
}
}
}
}

Expand All @@ -285,11 +290,10 @@ impl IncrementalFiles {
if let Some(path) = &self.outputs.keyboard
&& self.keyboard_pending
{
match self
.keyboard
.to_bytes()
.and_then(|bytes| write_atomically(path, &[&bytes]).map_err(|e| e.to_string()))
{
match self.keyboard.header().and_then(|header| {
write_atomically(path, &[&header, self.keyboard.presses()])
.map_err(|e| e.to_string())
}) {
Ok(()) => self.keyboard_pending = false,
Err(e) => {
tracing::error!("Failed to write keyboard data to {}: {}", path.display(), e)
Expand Down Expand Up @@ -1745,7 +1749,7 @@ mod input_epoch_tests {
#[cfg(test)]
mod incremental_writer_tests {
use super::*;
use cap_project::CursorEvents;
use cap_project::{CursorEvents, KeyboardEvents};
use std::sync::atomic::AtomicBool;
use std::time::Duration;

Expand Down
Loading