135 lines
4.4 KiB
Rust
135 lines
4.4 KiB
Rust
use image::codecs::jpeg::JpegEncoder;
|
|
use image::ExtendedColorType;
|
|
use std::io::Cursor;
|
|
use tracing::{debug, trace};
|
|
use x11rb::connection::Connection;
|
|
use x11rb::protocol::xproto::{self, ConnectionExt, ImageFormat};
|
|
use x11rb::rust_connection::RustConnection;
|
|
use xxhash_rust::xxh3::xxh3_64;
|
|
|
|
/// Connects to the X11 display and provides framebuffer capture.
|
|
pub struct FrameCapture {
|
|
conn: RustConnection,
|
|
root: u32,
|
|
width: u16,
|
|
height: u16,
|
|
prev_hash: u64,
|
|
}
|
|
|
|
impl FrameCapture {
|
|
/// Connect to the X display specified by `display_num` (e.g. 10 → ":10").
|
|
/// Retries a few times since Xvfb may take a moment to start.
|
|
pub async fn connect(display_num: u32) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
|
let display_str = format!(":{}", display_num);
|
|
let mut last_err = String::new();
|
|
|
|
for attempt in 0..30 {
|
|
match RustConnection::connect(Some(&display_str)) {
|
|
Ok((conn, screen_num)) => {
|
|
let screen = &conn.setup().roots[screen_num];
|
|
let root = screen.root;
|
|
let width = screen.width_in_pixels;
|
|
let height = screen.height_in_pixels;
|
|
debug!(
|
|
display = %display_str,
|
|
width,
|
|
height,
|
|
attempt,
|
|
"connected to X11 display"
|
|
);
|
|
return Ok(Self {
|
|
conn,
|
|
root,
|
|
width,
|
|
height,
|
|
prev_hash: 0,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
last_err = e.to_string();
|
|
trace!(attempt, err = %last_err, "X11 connect attempt failed, retrying...");
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"failed to connect to X display {} after 30 attempts: {}",
|
|
display_str, last_err
|
|
)
|
|
.into())
|
|
}
|
|
|
|
/// Capture the full framebuffer. Returns Some(jpeg_bytes) if the frame
|
|
/// changed since last capture, None if unchanged.
|
|
///
|
|
/// TODO: implement dirty region tracking — capture only changed tiles and
|
|
/// send them with (x, y, w, h) metadata to reduce bandwidth.
|
|
pub fn capture_frame(&mut self, quality: u8) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let reply = self
|
|
.conn
|
|
.get_image(
|
|
ImageFormat::Z_PIXMAP,
|
|
self.root,
|
|
0,
|
|
0,
|
|
self.width,
|
|
self.height,
|
|
u32::MAX,
|
|
)?
|
|
.reply()?;
|
|
|
|
let pixels = &reply.data;
|
|
|
|
// Fast hash comparison to skip unchanged frames
|
|
let hash = xxh3_64(pixels);
|
|
if hash == self.prev_hash {
|
|
return Ok(None);
|
|
}
|
|
self.prev_hash = hash;
|
|
|
|
// X11 ZPixmap gives us BGRA (or BGRx) — convert to RGB for JPEG
|
|
let pixel_count = (self.width as usize) * (self.height as usize);
|
|
let mut rgb = Vec::with_capacity(pixel_count * 3);
|
|
for i in 0..pixel_count {
|
|
let base = i * 4;
|
|
if base + 2 < pixels.len() {
|
|
rgb.push(pixels[base + 2]); // R
|
|
rgb.push(pixels[base + 1]); // G
|
|
rgb.push(pixels[base]); // B
|
|
}
|
|
}
|
|
|
|
// Encode as JPEG
|
|
let mut jpeg_buf = Cursor::new(Vec::with_capacity(256 * 1024));
|
|
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, quality);
|
|
encoder.encode(&rgb, self.width as u32, self.height as u32, ExtendedColorType::Rgb8)?;
|
|
|
|
let jpeg_bytes = jpeg_buf.into_inner();
|
|
trace!(
|
|
size = jpeg_bytes.len(),
|
|
width = self.width,
|
|
height = self.height,
|
|
"frame captured and encoded"
|
|
);
|
|
|
|
Ok(Some(jpeg_bytes))
|
|
}
|
|
|
|
pub fn width(&self) -> u16 {
|
|
self.width
|
|
}
|
|
|
|
pub fn height(&self) -> u16 {
|
|
self.height
|
|
}
|
|
|
|
/// Update the capture dimensions (e.g. after xrandr resize).
|
|
pub fn set_dimensions(&mut self, width: u16, height: u16) {
|
|
self.width = width;
|
|
self.height = height;
|
|
// Reset hash to force next capture to be sent
|
|
self.prev_hash = 0;
|
|
}
|
|
}
|