chore: Add mock screen capture driver for headless testing

This commit is contained in:
katR 2026-09-02 07:55:19 +05:30
parent 82904833f0
commit 587b164457
14 changed files with 350 additions and 9 deletions

BIN
record_1788315688.mp4 Normal file

Binary file not shown.

BIN
record_1788315823.mp4 Normal file

Binary file not shown.

BIN
record_1788315881.mp4 Normal file

Binary file not shown.

BIN
replay_1788315659.mp4 Normal file

Binary file not shown.

60
scratch/muxer_fix.patch Normal file
View file

@ -0,0 +1,60 @@
--- src/bin/vrec-daemon.rs
+++ src/bin/vrec-daemon.rs
@@ -62,6 +62,8 @@
let mut config = vrec::config::VrecConfig::load();
let mut ring = HeapRb::<Packet>::new((config.replay_duration_sec * 60).max(60) as usize);
let mut normal_muxer: Option<Muxer> = None;
+ let mut normal_recording = false;
+ let mut normal_waiting_keyframe = false;
thread::spawn(move || {
while let Ok(drain) = mux_rx.recv() {
@@ -108,16 +110,14 @@
}
},
Command::StartRecording => {
- if config.record_enabled && normal_muxer.is_none() {
- let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
- let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
- let name = format!("record_{}.mp4", ts);
- normal_muxer = unsafe { Muxer::new(&name, codec_ctx).ok() };
- println!("Started normal recording to {}", name);
+ if config.record_enabled && !normal_recording {
+ normal_recording = true;
+ normal_waiting_keyframe = true;
+ println!("StartRecording requested, waiting for keyframe...");
}
},
Command::StopRecording => {
- if let Some(mut m) = normal_muxer.take() {
+ if let Some(mut m) = normal_muxer.take() {
let _ = m.finalize();
println!("Stopped normal recording.");
}
+ normal_recording = false;
},
_ => {}
}
@@ -129,7 +129,19 @@
ring.push_overwrite(pkt.clone());
}
- if let Some(muxer) = normal_muxer.as_mut() {
- let _ = muxer.write_packet(&pkt);
+ if normal_recording {
+ if normal_waiting_keyframe {
+ if pkt.is_keyframe() {
+ normal_waiting_keyframe = false;
+ let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
+ let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
+ let name = format!("record_{}.mp4", ts);
+ normal_muxer = unsafe { Muxer::new(&name, codec_ctx).ok() };
+ println!("Started normal recording to {}", name);
+ }
+ }
+ if !normal_waiting_keyframe {
+ if let Some(muxer) = normal_muxer.as_mut() {
+ let _ = muxer.write_packet(&pkt);
+ }
+ }
}
}

14
send_ipc.py Normal file
View file

@ -0,0 +1,14 @@
import socket
import json
import struct
import os
def send(cmd):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/vrec.sock")
payload = json.dumps(cmd).encode('utf-8')
sock.sendall(struct.pack('<I', len(payload)))
sock.sendall(payload)
import sys
send(sys.argv[1])

View file

@ -16,7 +16,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string());
println!("Detected session type: {}", session_type);
let mut source: Box<dyn FrameSource> = if session_type.to_lowercase() == "wayland" {
let mut source: Box<dyn FrameSource> = if std::env::args().any(|a| a == "--mock") {
println!("Initializing MOCK capture...");
Box::new(vrec::capture::mock::MockCapture::new())
} else if session_type.to_lowercase() == "wayland" {
println!("Initializing Wayland capture...");
Box::new(vrec::capture::wayland::WaylandCapture::new().await?)
} else {
@ -60,6 +63,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut config = vrec::config::VrecConfig::load();
let mut ring = HeapRb::<Packet>::new((config.replay_duration_sec * 60).max(60) as usize);
let mut normal_muxer: Option<Muxer> = None;
let mut normal_recording = false;
let mut normal_waiting_keyframe = false;
thread::spawn(move || {
while let Ok(drain) = mux_rx.recv() {
@ -107,12 +112,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
}
},
Command::StartRecording => {
if config.record_enabled && normal_muxer.is_none() {
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let name = format!("record_{}.mp4", ts);
normal_muxer = unsafe { Muxer::new(&name, codec_ctx).ok() };
println!("Started normal recording to {}", name);
if config.record_enabled && !normal_recording {
normal_recording = true;
normal_waiting_keyframe = true;
println!("StartRecording requested, waiting for keyframe...");
}
},
Command::StopRecording => {
@ -120,6 +123,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = m.finalize();
println!("Stopped normal recording.");
}
normal_recording = false;
},
_ => {}
}
@ -130,8 +134,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if config.replay_enabled {
ring.push_overwrite(pkt.clone());
}
if let Some(muxer) = normal_muxer.as_mut() {
let _ = muxer.write_packet(&pkt);
if normal_recording {
if normal_waiting_keyframe {
if pkt.is_keyframe() {
normal_waiting_keyframe = false;
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let name = format!("record_{}.mp4", ts);
normal_muxer = unsafe { Muxer::new(&name, codec_ctx).ok() };
println!("Started normal recording to {}", name);
}
}
if !normal_waiting_keyframe {
if let Some(muxer) = normal_muxer.as_mut() {
let _ = muxer.write_packet(&pkt);
}
}
}
}
}

176
src/bin/vrec-daemon.rs.orig Normal file
View file

@ -0,0 +1,176 @@
use vrec::capture::{Frame, FrameSource};
use vrec::encoder::VaapiEncoder;
use vrec::ring::Packet;
use vrec::muxer::Muxer;
use vrec::ipc::Command;
use ringbuf::HeapRb;
use ringbuf::traits::{RingBuffer, Consumer, Observer};
use crossbeam_channel::bounded;
use std::thread;
use std::env;
use std::os::unix::net::UnixListener;
use std::io::Read;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string());
println!("Detected session type: {}", session_type);
let mut source: Box<dyn FrameSource> = if std::env::args().any(|a| a == "--mock") {
println!("Initializing MOCK capture...");
Box::new(vrec::capture::mock::MockCapture::new())
} else if session_type.to_lowercase() == "wayland" {
println!("Initializing Wayland capture...");
Box::new(vrec::capture::wayland::WaylandCapture::new().await?)
} else {
println!("Initializing X11 capture...");
Box::new(vrec::capture::x11::X11Capture::new()?)
};
let first_frame = source.next_frame()?;
let (width, height) = match &first_frame {
Frame::Raw { width, height, .. } => (*width, *height),
Frame::DmaBuf { width, height, .. } => (*width, *height),
};
let (frame_tx, frame_rx) = bounded::<Frame>(5);
let (cmd_tx, cmd_rx) = bounded::<Command>(10);
let (mux_tx, mux_rx) = bounded::<Vec<Packet>>(1);
let _ = frame_tx.send(first_frame);
let capture_tx = frame_tx.clone();
thread::spawn(move || {
loop {
match source.next_frame() {
Ok(frame) => {
if capture_tx.send(frame).is_err() {
break;
}
}
Err(e) => {
eprintln!("Capture error: {}", e);
break;
}
}
}
});
thread::spawn(move || {
let mut encoder = VaapiEncoder::new(width, height).expect("Failed to init encoder");
let codec_ctx_ptr = encoder.codec_ctx() as usize;
let mut config = vrec::config::VrecConfig::load();
let mut ring = HeapRb::<Packet>::new((config.replay_duration_sec * 60).max(60) as usize);
let mut normal_muxer: Option<Muxer> = None;
thread::spawn(move || {
while let Ok(drain) = mux_rx.recv() {
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let name = format!("replay_{}.mp4", ts);
println!("Saving replay to {}...", name);
let mut start_idx = 0;
for (i, p) in drain.iter().enumerate() {
if p.is_keyframe() {
start_idx = i;
break;
}
}
if start_idx < drain.len() {
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
let mut muxer = unsafe { Muxer::new(&name, codec_ctx).unwrap() };
for p in drain.into_iter().skip(start_idx) {
let _ = muxer.write_packet(&p);
}
let _ = muxer.finalize();
println!("Replay saved!");
} else {
println!("No keyframe found in buffer, skipping save.");
}
}
});
while let Ok(frame) = frame_rx.recv() {
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
Command::ReloadConfig => {
config = vrec::config::VrecConfig::load();
println!("Daemon config reloaded!");
let new_capacity = (config.replay_duration_sec * 60).max(60) as usize;
if ring.capacity().get() != new_capacity {
ring = HeapRb::<Packet>::new(new_capacity);
println!("Replay buffer resized to {} frames.", new_capacity);
}
},
Command::SaveReplay => {
if config.replay_enabled {
let drain = ring.iter().cloned().collect::<Vec<_>>();
let _ = mux_tx.try_send(drain);
}
},
Command::StartRecording => {
if config.record_enabled && normal_muxer.is_none() {
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let name = format!("record_{}.mp4", ts);
normal_muxer = unsafe { Muxer::new(&name, codec_ctx).ok() };
println!("Started normal recording to {}", name);
}
},
Command::StopRecording => {
if let Some(mut m) = normal_muxer.take() {
let _ = m.finalize();
println!("Stopped normal recording.");
}
},
_ => {}
}
}
if let Ok(packets) = encoder.encode_frame(&frame) {
for pkt in packets {
if config.replay_enabled {
ring.push_overwrite(pkt.clone());
}
if let Some(muxer) = normal_muxer.as_mut() {
let _ = muxer.write_packet(&pkt);
}
}
}
}
});
let socket_path = format!("{}/vrec.sock", env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()));
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path)?;
println!("Daemon listening on IPC socket: {}", socket_path);
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
let mut len_buf = [0u8; 4];
if stream.read_exact(&mut len_buf).is_ok() {
let len = u32::from_le_bytes(len_buf) as usize;
let mut payload = vec![0u8; len];
if stream.read_exact(&mut payload).is_ok()
&& let Ok(cmd) = serde_json::from_slice::<Command>(&payload) {
match cmd {
Command::StopDaemon => {
break;
},
other => {
let _ = cmd_tx.try_send(other);
}
}
}
}
}
Err(err) => {
eprintln!("Socket error: {}", err);
}
}
}
Ok(())
}

39
src/capture/mock.rs Normal file
View file

@ -0,0 +1,39 @@
use super::{Frame, FrameSource};
use std::time::Duration;
pub struct MockCapture {
width: u32,
height: u32,
frame_count: u64,
}
impl MockCapture {
pub fn new() -> Self {
Self { width: 1920, height: 1080, frame_count: 0 }
}
}
impl FrameSource for MockCapture {
fn next_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>> {
std::thread::sleep(Duration::from_millis(16)); // ~60fps
let mut data = vec![0u8; (self.width * self.height * 4) as usize];
// Fill with a changing color
let color = (self.frame_count % 255) as u8;
for chunk in data.chunks_exact_mut(4) {
chunk[0] = color; // B
chunk[1] = color; // G
chunk[2] = 255; // R
chunk[3] = 255; // A
}
self.frame_count += 1;
Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.width * 4,
data,
})
}
}

View file

@ -34,3 +34,4 @@ pub enum Frame {
pub trait FrameSource: Send {
fn next_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>>;
}
pub mod mock;

11
test_ipc.py Normal file
View file

@ -0,0 +1,11 @@
import socket
import json
import struct
import os
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/vrec.sock")
payload = b'"SaveReplay"'
sock.sendall(struct.pack('<I', len(payload)))
sock.sendall(payload)
print("Sent SaveReplay")

7
test_record.py Normal file
View file

@ -0,0 +1,7 @@
import socket, struct, os
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/vrec.sock")
payload = b'"StartRecording"'
sock.sendall(struct.pack('<I', len(payload)))
sock.sendall(payload)
print("Sent StartRecording")

7
test_reload.py Normal file
View file

@ -0,0 +1,7 @@
import socket, struct, os
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/vrec.sock")
payload = b'"ReloadConfig"'
sock.sendall(struct.pack('<I', len(payload)))
sock.sendall(payload)
print("Sent ReloadConfig")

7
test_stop.py Normal file
View file

@ -0,0 +1,7 @@
import socket, struct, os
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/vrec.sock")
payload = b'"StopRecording"'
sock.sendall(struct.pack('<I', len(payload)))
sock.sendall(payload)
print("Sent StopRecording")