From 6d42ce87975644ba9205bf5b56b352a1946575be Mon Sep 17 00:00:00 2001 From: aqua Date: Mon, 26 Oct 2026 21:40:04 +0530 Subject: [PATCH] perf(recording): append keyboard presses to the incremental file encoding --- ...keyboard-incremental-encoding-benchmark.rs | 87 +++++++++++++++++ crates/project/src/keyboard.rs | 93 +++++++++++++++++++ crates/recording/src/cursor.rs | 28 +++--- 3 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 crates/project/examples/keyboard-incremental-encoding-benchmark.rs diff --git a/crates/project/examples/keyboard-incremental-encoding-benchmark.rs b/crates/project/examples/keyboard-incremental-encoding-benchmark.rs new file mode 100644 index 00000000000..e3116674921 --- /dev/null +++ b/crates/project/examples/keyboard-incremental-encoding-benchmark.rs @@ -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 { + (flush * PRESSES_PER_FLUSH..(flush + 1) * PRESSES_PER_FLUSH) + .map(press) + .collect() +} + +fn whole(batches: &[Vec]) -> Result<(f64, f64, Vec), Box> { + 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]) -> Result<(f64, f64, Vec), Box> { + 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> { + 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]) -> _), + ("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(()) +} diff --git a/crates/project/src/keyboard.rs b/crates/project/src/keyboard.rs index e423db84259..ff7753b9098 100644 --- a/crates/project/src/keyboard.rs +++ b/crates/project/src/keyboard.rs @@ -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, + 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, 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", ]; @@ -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 { diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 45a8ee07225..f2dfcee4341 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -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::{ @@ -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, moves_json: Vec, - keyboard: KeyboardEvents, + keyboard: KeyboardEventsEncoder, cursor_pending: bool, keyboard_pending: bool, } @@ -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, } @@ -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}"); + } + } } } @@ -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) @@ -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;