feat: Phase 1 MVP with audit fixes — Wayland screen capture + VAAPI encoding
Phase 1 MVP implementation of wl-webrtc: Wayland screen capture tool with hardware-accelerated VAAPI H.264 encoding and WebTransport output. Includes all 9 runtime bug fixes from code audit (fix-audit-issues plan): CRITICAL: - C2: h264_metadata BSF with repeat_sps/repeat_pps in encode pipeline - C4: FpsLimit wired as timing gate in on_copy_complete HIGH: - C3+A2: DRM device discovery via dmabuf feedback MainDevice event, unified resolve_drm_path() helper (CLI > compositor > auto > fallback) - H2: Separate physical_size (mm) from mode_size (pixels) in wl_output - H1+A3: Multi-output warning + named-output-not-found error MEDIUM: - M5: tv_sec u32->u64 to avoid Y2106 timestamp truncation - M4: Guard against SHM Buffer event (DMA-BUF only) Key components: - src/avhw.rs: FFmpeg VAAPI encoder + filter graph + BSF pipeline - src/state.rs: Wayland event loop + output negotiation + screencopy - src/cap_wlr_screencopy.rs: wlr-screencopy capture source - src/fps_limit.rs: Frame rate limiting with configurable target - src/transform.rs: Frame format conversion utilities
This commit is contained in:
45
src/args.rs
Normal file
45
src/args.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "wl-webrtc", about = "Wayland screen capture and encoding tool")]
|
||||
pub struct Args {
|
||||
/// Output file path (e.g., output.mp4, output.mkv)
|
||||
#[arg(short, long)]
|
||||
pub output: String,
|
||||
|
||||
/// Wayland output name to capture
|
||||
#[arg(long)]
|
||||
pub output_name: Option<String>,
|
||||
|
||||
/// Target frames per second
|
||||
#[arg(long, default_value_t = 30)]
|
||||
pub fps: u32,
|
||||
|
||||
/// Video codec (h264 only for MVP)
|
||||
#[arg(long, default_value = "h264")]
|
||||
pub codec: String,
|
||||
|
||||
/// Hardware acceleration method (vaapi only for MVP)
|
||||
#[arg(long, default_value = "vaapi")]
|
||||
pub hw_accel: String,
|
||||
|
||||
/// DRM render device path (e.g., /dev/dri/renderD128)
|
||||
#[arg(long)]
|
||||
pub drm_device: Option<String>,
|
||||
|
||||
/// Target bitrate in bits per second
|
||||
#[arg(long)]
|
||||
pub bitrate: Option<u64>,
|
||||
|
||||
/// Group of Pictures (GOP) size
|
||||
#[arg(long)]
|
||||
pub gop_size: Option<u32>,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Port for WebTransport server (Phase 2, unused in MVP)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub port: u16,
|
||||
}
|
||||
672
src/avhw.rs
Normal file
672
src/avhw.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi as ffi;
|
||||
use ffmpeg_next::packet::Mut as _;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BSF FFI — ffmpeg-sys-next does not expose the BSF API; declare manually.
|
||||
// Linked from libavcodec (always present when avcodec feature is enabled).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[repr(C)]
|
||||
pub struct AVBitStreamFilter {
|
||||
_opaque: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct AVBSFContext {
|
||||
av_class: *const ffi::AVClass,
|
||||
filter: *const AVBitStreamFilter,
|
||||
priv_data: *mut libc::c_void,
|
||||
par_in: *mut ffi::AVCodecParameters,
|
||||
par_out: *mut ffi::AVCodecParameters,
|
||||
time_base_in: ffi::AVRational,
|
||||
time_base_out: ffi::AVRational,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
pub fn av_bsf_get_by_name(name: *const libc::c_char) -> *const AVBitStreamFilter;
|
||||
pub fn av_bsf_alloc(
|
||||
filter: *const AVBitStreamFilter,
|
||||
ctx: *mut *mut AVBSFContext,
|
||||
) -> libc::c_int;
|
||||
pub fn av_bsf_init(ctx: *mut AVBSFContext) -> libc::c_int;
|
||||
pub fn av_bsf_send_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
|
||||
pub fn av_bsf_receive_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
|
||||
pub fn av_bsf_free(ctx: *mut *mut AVBSFContext);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AvHwDevCtx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AvHwDevCtx {
|
||||
ptr: *mut ffi::AVBufferRef,
|
||||
}
|
||||
|
||||
unsafe impl Send for AvHwDevCtx {}
|
||||
|
||||
impl AvHwDevCtx {
|
||||
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
|
||||
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
|
||||
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
let ret = unsafe {
|
||||
ffi::av_hwdevice_ctx_create(
|
||||
&mut p,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
device_cstr.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to create VAAPI device context from {}: error {ret}",
|
||||
drm_device.display()
|
||||
);
|
||||
}
|
||||
Ok(Self { ptr: p })
|
||||
}
|
||||
|
||||
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
|
||||
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
|
||||
unsafe { ffi::av_buffer_ref(self.ptr) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvHwDevCtx {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AvHwFrameCtx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AvHwFrameCtx {
|
||||
ptr: *mut ffi::AVBufferRef,
|
||||
}
|
||||
|
||||
unsafe impl Send for AvHwFrameCtx {}
|
||||
|
||||
impl AvHwFrameCtx {
|
||||
fn new_inner(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
w: u32,
|
||||
h: u32,
|
||||
sw_fmt: ff::format::Pixel,
|
||||
) -> Result<Self> {
|
||||
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
|
||||
if p.is_null() {
|
||||
bail!("av_hwframe_ctx_alloc returned null");
|
||||
}
|
||||
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
|
||||
// Its .data field points to an AVHWFramesContext that we must configure.
|
||||
unsafe {
|
||||
let fc = (*p).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ff::format::Pixel::VAAPI.into();
|
||||
(*fc).sw_format = sw_fmt.into();
|
||||
(*fc).width = w as i32;
|
||||
(*fc).height = h as i32;
|
||||
(*fc).initial_pool_size = 4;
|
||||
}
|
||||
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
|
||||
if ret < 0 {
|
||||
// SAFETY: p is valid but init failed; clean up.
|
||||
unsafe { ffi::av_buffer_unref(&mut p) };
|
||||
bail!("av_hwframe_ctx_init failed: error {ret}");
|
||||
}
|
||||
Ok(Self { ptr: p })
|
||||
}
|
||||
|
||||
pub fn for_capture(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
w: u32,
|
||||
h: u32,
|
||||
sw_fmt: ff::format::Pixel,
|
||||
) -> Result<Self> {
|
||||
Self::new_inner(hw_dev, w, h, sw_fmt)
|
||||
}
|
||||
|
||||
pub fn for_encode(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
w: u32,
|
||||
h: u32,
|
||||
sw_fmt: ff::format::Pixel,
|
||||
) -> Result<Self> {
|
||||
Self::new_inner(hw_dev, w, h, sw_fmt)
|
||||
}
|
||||
|
||||
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
|
||||
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
|
||||
unsafe { ffi::av_buffer_ref(self.ptr) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvHwFrameCtx {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EncState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct EncState {
|
||||
enc_video: ff::codec::encoder::video::Video,
|
||||
bsf_ctx: *mut AVBSFContext,
|
||||
frames_rgb: AvHwFrameCtx,
|
||||
frames_yuv: AvHwFrameCtx,
|
||||
video_filter: ff::filter::Graph,
|
||||
hw_device_ctx: AvHwDevCtx,
|
||||
octx: ff::format::context::Output,
|
||||
starting_timestamp: Option<i64>,
|
||||
frames_written: bool,
|
||||
}
|
||||
|
||||
unsafe impl Send for EncState {}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
impl EncState {
|
||||
pub fn new(
|
||||
drm_device: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
fps: u32,
|
||||
) -> Result<Self> {
|
||||
// 1. VAAPI device
|
||||
let hw_device_ctx = AvHwDevCtx::new_vaapi(drm_device)?;
|
||||
|
||||
// 2. Frame contexts (capture=XRGB/RGBZ, encode=NV12)
|
||||
let frames_rgb = AvHwFrameCtx::for_capture(
|
||||
&hw_device_ctx,
|
||||
width,
|
||||
height,
|
||||
ff::format::Pixel::RGBZ,
|
||||
)?;
|
||||
let frames_yuv = AvHwFrameCtx::for_encode(
|
||||
&hw_device_ctx,
|
||||
width,
|
||||
height,
|
||||
ff::format::Pixel::NV12,
|
||||
)?;
|
||||
|
||||
// 3. Find h264_vaapi encoder
|
||||
let codec = ff::encoder::find_by_name("h264_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("h264_vaapi encoder not found"))?;
|
||||
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(ff::format::Pixel::VAAPI);
|
||||
enc.set_bit_rate(bitrate as usize);
|
||||
enc.set_gop(gop_size);
|
||||
enc.set_time_base(ff::Rational::new(1, fps as i32));
|
||||
enc.set_max_b_frames(0);
|
||||
|
||||
// SAFETY: AV_CODEC_FLAG_GLOBAL_HEADER must be set BEFORE opening the encoder.
|
||||
// It triggers SPS/PPS extradata generation needed by the muxer for
|
||||
// Annex B to AVCC conversion.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
|
||||
}
|
||||
// SAFETY: Assign hw device and frames ctx to the encoder.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).hw_device_ctx = hw_device_ctx.ref_clone();
|
||||
(*enc.as_mut_ptr()).hw_frames_ctx = frames_yuv.ref_clone();
|
||||
}
|
||||
|
||||
// 4. Open encoder. Video::open() returns Encoder(Video); .0 extracts the Video.
|
||||
let opened = enc.open().map_err(|e| {
|
||||
anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}")
|
||||
})?;
|
||||
let enc_video = opened.0;
|
||||
|
||||
// --- BSF init (after encoder open, before filter graph) ---
|
||||
// SAFETY: av_bsf_get_by_name returns a pointer to a static filter definition.
|
||||
let bsf_name = CString::new("h264_metadata").unwrap();
|
||||
let filter = unsafe { av_bsf_get_by_name(bsf_name.as_ptr()) };
|
||||
if filter.is_null() {
|
||||
bail!("h264_metadata BSF not found in FFmpeg build");
|
||||
}
|
||||
|
||||
let mut bsf_ctx: *mut AVBSFContext = ptr::null_mut();
|
||||
let ret = unsafe { av_bsf_alloc(filter, &mut bsf_ctx) };
|
||||
if ret < 0 {
|
||||
bail!("av_bsf_alloc failed: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: avcodec_parameters_from_context copies FROM AVCodecContext TO AVCodecParameters.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_parameters_from_context((*bsf_ctx).par_in, enc_video.as_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
// SAFETY: bsf_ctx was allocated but not yet initialized — safe to free
|
||||
unsafe { av_bsf_free(&mut bsf_ctx) };
|
||||
bail!("avcodec_parameters_from_context for BSF failed: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: time_base_in is a plain AVRational field, safe to write
|
||||
unsafe {
|
||||
(*bsf_ctx).time_base_in = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// Set repeat_sps=1
|
||||
let key_sps = CString::new("repeat_sps").unwrap();
|
||||
let val_one = CString::new("1").unwrap();
|
||||
let ret = unsafe {
|
||||
ffi::av_opt_set((*bsf_ctx).priv_data, key_sps.as_ptr(), val_one.as_ptr(), 0)
|
||||
};
|
||||
if ret < 0 {
|
||||
// SAFETY: bsf_ctx allocated but not fully initialized — safe to free
|
||||
unsafe { av_bsf_free(&mut bsf_ctx) };
|
||||
bail!("av_opt_set repeat_sps failed: error {ret}");
|
||||
}
|
||||
|
||||
// Set repeat_pps=1
|
||||
let key_pps = CString::new("repeat_pps").unwrap();
|
||||
let ret = unsafe {
|
||||
ffi::av_opt_set((*bsf_ctx).priv_data, key_pps.as_ptr(), val_one.as_ptr(), 0)
|
||||
};
|
||||
if ret < 0 {
|
||||
// SAFETY: bsf_ctx allocated, repeat_sps set but not init'd — safe to free
|
||||
unsafe { av_bsf_free(&mut bsf_ctx) };
|
||||
bail!("av_opt_set repeat_pps failed: error {ret}");
|
||||
}
|
||||
|
||||
// Initialize BSF
|
||||
let ret = unsafe { av_bsf_init(bsf_ctx) };
|
||||
if ret < 0 {
|
||||
// SAFETY: bsf_ctx allocated, params set but init failed — safe to free
|
||||
unsafe { av_bsf_free(&mut bsf_ctx) };
|
||||
bail!("av_bsf_init failed: error {ret}");
|
||||
}
|
||||
|
||||
// 5. Filter graph (inline)
|
||||
let video_filter =
|
||||
build_filter_graph(&hw_device_ctx, &frames_rgb, width, height, fps)?;
|
||||
|
||||
// 6. Muxer setup (strict order)
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
|
||||
// SAFETY: avformat_alloc_output_context2 creates format context from
|
||||
// the file extension. Does NOT open the file.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: avformat_query_codec checks codec+format compatibility.
|
||||
let codec_id = unsafe { (*enc_video.as_ptr()).codec_id };
|
||||
let oformat = unsafe { (*fmt_ctx_ptr).oformat };
|
||||
let compat = unsafe {
|
||||
ffi::avformat_query_codec(oformat, codec_id, ffi::FF_COMPLIANCE_NORMAL as i32)
|
||||
};
|
||||
if compat < 0 {
|
||||
bail!("H.264 codec not supported by output container format");
|
||||
}
|
||||
|
||||
// SAFETY: avformat_new_stream creates a new stream in the format context.
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create new stream in output context");
|
||||
}
|
||||
|
||||
// SAFETY: avcodec_parameters_from_context copies encoder params + extradata.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!("Failed to copy encoder parameters to stream: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: Copy encoder time_base to stream.
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// SAFETY: avio_open opens the output file for writing.
|
||||
let ret = unsafe {
|
||||
ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to open output file '{}': error {ret}",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: avformat_write_header writes the container header.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write output header: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: We created fmt_ctx_ptr above and it's valid.
|
||||
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
|
||||
Ok(Self {
|
||||
enc_video,
|
||||
bsf_ctx,
|
||||
frames_rgb,
|
||||
frames_yuv,
|
||||
video_filter,
|
||||
hw_device_ctx,
|
||||
octx,
|
||||
starting_timestamp: None,
|
||||
frames_written: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
|
||||
&self.frames_rgb
|
||||
}
|
||||
|
||||
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
|
||||
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
|
||||
// SAFETY: hw_frame is a valid VAAPI hardware frame from capture.
|
||||
filter_src.add(hw_frame).map_err(|e| {
|
||||
anyhow::anyhow!("Filter source add failed: {e}")
|
||||
})?;
|
||||
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {
|
||||
if filtered.pts().is_none() {
|
||||
filtered.set_pts(hw_frame.pts());
|
||||
}
|
||||
}
|
||||
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
|
||||
Err(e) => bail!("Filter sink get frame failed: {e}"),
|
||||
}
|
||||
|
||||
let pts = filtered.pts().unwrap_or(0);
|
||||
if self.starting_timestamp.is_none() {
|
||||
self.starting_timestamp = Some(pts);
|
||||
}
|
||||
let start_ts = self.starting_timestamp.unwrap();
|
||||
|
||||
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!("avcodec_send_frame failed: error {ret}");
|
||||
}
|
||||
self.drain_encoder(start_ts)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
// Flush filter graph
|
||||
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
let _ = filter_src.flush();
|
||||
|
||||
// Drain filter
|
||||
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!("avcodec_send_frame failed during flush: error {ret}");
|
||||
}
|
||||
self.drain_encoder(start_ts)?;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: Sending null frame signals end of stream.
|
||||
unsafe {
|
||||
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
|
||||
}
|
||||
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
self.drain_encoder(start_ts)?;
|
||||
|
||||
// SAFETY: Sending null packet signals end-of-stream to BSF
|
||||
unsafe { av_bsf_send_packet(self.bsf_ctx, ptr::null_mut()) };
|
||||
loop {
|
||||
let mut bsf_pkt = ff::Packet::empty();
|
||||
let ret = unsafe {
|
||||
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
|
||||
};
|
||||
if ret < 0 { break; }
|
||||
let enc_tb = self.enc_video.time_base();
|
||||
let stream_tb = unsafe {
|
||||
let streams = (*self.octx.as_ptr()).streams;
|
||||
let st = *streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
bsf_pkt.rescale_ts(enc_tb, stream_tb);
|
||||
if let Some(pts) = bsf_pkt.pts() {
|
||||
bsf_pkt.set_pts(Some(pts - start_ts));
|
||||
}
|
||||
if let Some(dts) = bsf_pkt.dts() {
|
||||
bsf_pkt.set_dts(Some(dts - start_ts));
|
||||
}
|
||||
bsf_pkt.set_stream(0);
|
||||
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
|
||||
anyhow::anyhow!("Failed to write BSF flush packet: {e}")
|
||||
})?;
|
||||
self.frames_written = true;
|
||||
}
|
||||
|
||||
// Write trailer only if at least one frame was encoded.
|
||||
if self.frames_written {
|
||||
self.octx.write_trailer().map_err(|e| {
|
||||
anyhow::anyhow!("Failed to write trailer: {e}")
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_encoder(&mut self, start_ts: i64) -> Result<()> {
|
||||
let stream_index: i32 = 0;
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// SAFETY: avcodec_receive_packet retrieves an encoded packet.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
bail!("avcodec_receive_packet failed: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: av_bsf_send_packet sends the encoded packet through the BSF filter.
|
||||
// On success, the BSF takes ownership of the packet data (via av_packet_move_ref).
|
||||
let ret = unsafe { av_bsf_send_packet(self.bsf_ctx, pkt.as_mut_ptr()) };
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) {
|
||||
// BSF buffer full — break and retry next drain cycle
|
||||
break;
|
||||
}
|
||||
if ret < 0 {
|
||||
bail!("av_bsf_send_packet failed: error {ret}");
|
||||
}
|
||||
|
||||
// Drain all BSF output packets
|
||||
loop {
|
||||
let mut bsf_pkt = ff::Packet::empty();
|
||||
// SAFETY: av_bsf_receive_packet retrieves a BSF-processed packet.
|
||||
let ret = unsafe {
|
||||
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
|
||||
};
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) {
|
||||
break; // No more output yet
|
||||
}
|
||||
if ret == ffi::AVERROR_EOF {
|
||||
break; // BSF drained
|
||||
}
|
||||
if ret < 0 {
|
||||
bail!("av_bsf_receive_packet failed: error {ret}");
|
||||
}
|
||||
|
||||
// Rescale and offset on BSF output packet (NOT original pkt)
|
||||
let enc_tb = self.enc_video.time_base();
|
||||
let stream_tb = unsafe {
|
||||
let streams = (*self.octx.as_ptr()).streams;
|
||||
let st = *streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
bsf_pkt.rescale_ts(enc_tb, stream_tb);
|
||||
|
||||
if let Some(pts) = bsf_pkt.pts() {
|
||||
bsf_pkt.set_pts(Some(pts - start_ts));
|
||||
}
|
||||
if let Some(dts) = bsf_pkt.dts() {
|
||||
bsf_pkt.set_dts(Some(dts - start_ts));
|
||||
}
|
||||
|
||||
bsf_pkt.set_stream(stream_index as usize);
|
||||
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
|
||||
anyhow::anyhow!("Failed to write packet: {e}")
|
||||
})?;
|
||||
|
||||
self.frames_written = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EncState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: av_bsf_free releases the BSF context and all associated resources.
|
||||
// It handles null safely (returns immediately if *pctx is null).
|
||||
if !self.bsf_ctx.is_null() {
|
||||
unsafe { av_bsf_free(&mut self.bsf_ctx) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter graph (inline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_filter_graph(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_rgb: &AvHwFrameCtx,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
) -> Result<ff::filter::Graph> {
|
||||
let mut graph = ff::filter::Graph::new();
|
||||
|
||||
let buffersrc = ff::filter::find("buffer")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
|
||||
let buffersink = ff::filter::find("buffersink")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
|
||||
let format_filter = ff::filter::find("format")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'format' not found"))?;
|
||||
let scale_vaapi = ff::filter::find("scale_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
|
||||
|
||||
// buffersrc — use AVBufferSrcParameters to set hw_frames_ctx properly
|
||||
let args = format!(
|
||||
"video_size={}x{}:pix_fmt={}:time_base=1/{fps}:pixel_aspect=1/1",
|
||||
width,
|
||||
height,
|
||||
Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32,
|
||||
);
|
||||
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
|
||||
|
||||
// SAFETY: av_buffersrc_parameters_alloc allocates params for the buffersrc.
|
||||
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
|
||||
if par.is_null() {
|
||||
bail!("av_buffersrc_parameters_alloc returned null");
|
||||
}
|
||||
// SAFETY: Set hw_frames_ctx on the buffersrc parameters, then apply.
|
||||
unsafe {
|
||||
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
|
||||
(*par).width = width as i32;
|
||||
(*par).height = height as i32;
|
||||
(*par).time_base = ffi::AVRational { num: 1, den: fps as i32 };
|
||||
(*par).hw_frames_ctx = frames_rgb.ref_clone();
|
||||
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
|
||||
ffi::av_freep(par as *mut _ as *mut _);
|
||||
if ret < 0 {
|
||||
bail!("av_buffersrc_parameters_set failed: error {ret}");
|
||||
}
|
||||
}
|
||||
|
||||
// format filter: negotiate pixel format to NV12
|
||||
let mut fmt_ctx = graph.add(&format_filter, "fmt", "pix_fmts=nv12")?;
|
||||
|
||||
// scale_vaapi: hardware scaling and colourspace conversion
|
||||
let mut scale_ctx = graph.add(&scale_vaapi, "scale", &format!("{width}:{height}"))?;
|
||||
// SAFETY: scale_vaapi needs hw_device_ctx for VAAPI device access.
|
||||
unsafe {
|
||||
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||
}
|
||||
|
||||
// buffersink
|
||||
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
|
||||
|
||||
// Link: src -> format -> scale -> sink
|
||||
src_ctx.link(0, &mut fmt_ctx, 0);
|
||||
fmt_ctx.link(0, &mut scale_ctx, 0);
|
||||
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||
|
||||
graph.validate().map_err(|e| {
|
||||
anyhow::anyhow!("Filter graph validation failed: {e}")
|
||||
})?;
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
64
src/cap_wlr_screencopy.rs
Normal file
64
src/cap_wlr_screencopy.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use anyhow::Result;
|
||||
use wayland_client::globals::GlobalList;
|
||||
use wayland_client::protocol::wl_buffer::WlBuffer;
|
||||
use wayland_client::protocol::wl_output::WlOutput;
|
||||
use wayland_client::QueueHandle;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
|
||||
|
||||
use crate::state::{CaptureSource, OutputInfo, State};
|
||||
|
||||
/// wlr-screencopy capture backend.
|
||||
///
|
||||
/// Holds the current in-flight frame protocol object. The
|
||||
/// `ZwlrScreencopyManagerV1` is stored separately in
|
||||
/// `State::EverythingButFmt` because binding it requires a `Dispatch`
|
||||
/// impl that lives in state.rs (T6b).
|
||||
pub struct CapWlrScreencopy {
|
||||
/// The active frame object for the current capture cycle.
|
||||
/// Set by Dispatch impls after `manager.capture_output()`, cleared
|
||||
/// by `on_done_with_frame()`.
|
||||
pub current_frame: Option<ZwlrScreencopyFrameV1>,
|
||||
}
|
||||
|
||||
impl CaptureSource for CapWlrScreencopy {
|
||||
/// Unit type: wlr-screencopy is fully asynchronous — `alloc_frame()`
|
||||
/// always returns `None`. The frame object is created by Dispatch
|
||||
/// impls calling `manager.capture_output()`, not by this method.
|
||||
type Frame = ();
|
||||
|
||||
fn new(
|
||||
_gm: &GlobalList,
|
||||
_output: &WlOutput,
|
||||
_output_info: &OutputInfo,
|
||||
_qh: &QueueHandle<State<Self>>,
|
||||
) -> Result<Self> {
|
||||
// Manager binding happens in state.rs during the ProbingOutputs →
|
||||
// EverythingButFmt stage transition (T6b). It requires a Dispatch
|
||||
// impl that doesn't exist yet, so we cannot call gm.bind() here.
|
||||
Ok(Self {
|
||||
current_frame: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn alloc_frame(&mut self) -> Option<Self::Frame> {
|
||||
// wlr-screencopy is asynchronous: the Dispatch impl creates a new
|
||||
// ZwlrScreencopyFrameV1 which triggers the buffer allocation flow
|
||||
// (buffer event → negotiate format → create DMA-BUF). This method
|
||||
// always returns None.
|
||||
None
|
||||
}
|
||||
|
||||
fn queue_copy(&mut self, buffer: &WlBuffer, _qh: &QueueHandle<State<Self>>) {
|
||||
if let Some(frame) = &self.current_frame {
|
||||
frame.copy(buffer);
|
||||
} else {
|
||||
tracing::warn!("queue_copy: no current wlr-screencopy frame");
|
||||
}
|
||||
}
|
||||
|
||||
fn on_done_with_frame(&mut self, _frame: Self::Frame) {
|
||||
if let Some(frame) = self.current_frame.take() {
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/fps_limit.rs
Normal file
77
src/fps_limit.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct FpsLimit<T> {
|
||||
on_deck: Option<(T, Instant)>,
|
||||
min_interval: Duration,
|
||||
}
|
||||
|
||||
impl<T> FpsLimit<T> {
|
||||
pub fn new(fps: u32) -> Self {
|
||||
Self {
|
||||
on_deck: None,
|
||||
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a new frame. Returns:
|
||||
/// - Some(previous_frame) if enough time elapsed since previous frame
|
||||
/// - None if frame is buffered (first frame) or previous is dropped (too close)
|
||||
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
|
||||
let old = self.on_deck.replace((frame, timestamp));
|
||||
match old {
|
||||
None => None, // First frame — buffer it
|
||||
Some((old_frame, old_ts)) => {
|
||||
if timestamp.duration_since(old_ts) >= self.min_interval {
|
||||
Some(old_frame) // Enough time — output previous
|
||||
} else {
|
||||
None // Too close — discard previous, keep new
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the last buffered frame at end of stream
|
||||
pub fn flush(&mut self) -> Option<T> {
|
||||
self.on_deck.take().map(|(frame, _ts)| frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_frame_is_buffered() {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
let result = limiter.on_new_frame(1u32, now);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_too_close_drops_old() {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_far_enough_output_old() {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
let result = limiter.on_new_frame(2, now + Duration::from_millis(40));
|
||||
assert_eq!(result, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_returns_last_buffered() {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
assert_eq!(limiter.flush(), Some(1));
|
||||
assert_eq!(limiter.flush(), None);
|
||||
}
|
||||
}
|
||||
148
src/main.rs
Normal file
148
src/main.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use mio::unix::SourceFd;
|
||||
use mio::{Events, Interest, Poll, Token};
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
use wayland_client::Connection;
|
||||
|
||||
mod args;
|
||||
mod avhw;
|
||||
mod cap_wlr_screencopy;
|
||||
mod fps_limit;
|
||||
mod state;
|
||||
mod transform;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::cap_wlr_screencopy::CapWlrScreencopy;
|
||||
use crate::state::State;
|
||||
|
||||
const TOKEN_WAYLAND: Token = Token(0);
|
||||
const TOKEN_QUIT: Token = Token(1);
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(if args.verbose {
|
||||
tracing::Level::DEBUG
|
||||
} else {
|
||||
tracing::Level::INFO
|
||||
})
|
||||
.init();
|
||||
|
||||
tracing::info!("wl-webrtc starting");
|
||||
tracing::debug!("Args: {:?}", args);
|
||||
|
||||
if args.codec != "h264" {
|
||||
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
|
||||
}
|
||||
|
||||
// Connect to Wayland compositor
|
||||
let conn = Connection::connect_to_env()?;
|
||||
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
|
||||
|
||||
// Get the Wayland socket fd for mio polling.
|
||||
// Use prepare_read() once to obtain the fd, then immediately drop the guard.
|
||||
let wayland_fd = {
|
||||
let guard = queue
|
||||
.prepare_read()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to prepare Wayland read"))?;
|
||||
guard.connection_fd().as_raw_fd()
|
||||
};
|
||||
|
||||
// Create initial state
|
||||
let qhandle = queue.handle();
|
||||
let mut state = State::new(gm, args, qhandle);
|
||||
|
||||
// Dispatch initial round to bind all globals (screencopy manager, dmabuf, outputs)
|
||||
queue.blocking_dispatch(&mut state)?;
|
||||
|
||||
// Set up mio event loop
|
||||
let mut poll = Poll::new()?;
|
||||
let mut events = Events::with_capacity(8);
|
||||
|
||||
// Register Wayland fd with mio
|
||||
poll.registry().register(
|
||||
&mut SourceFd(&wayland_fd),
|
||||
TOKEN_WAYLAND,
|
||||
Interest::READABLE,
|
||||
)?;
|
||||
|
||||
// Register signal handler
|
||||
let mut signals = signal_hook_mio::v1_0::Signals::new(&[
|
||||
signal_hook::consts::SIGINT,
|
||||
signal_hook::consts::SIGTERM,
|
||||
])?;
|
||||
poll.registry()
|
||||
.register(&mut signals, TOKEN_QUIT, Interest::READABLE)?;
|
||||
|
||||
tracing::info!("Event loop started");
|
||||
|
||||
// Main event loop
|
||||
let mut running = true;
|
||||
while running {
|
||||
// Wayland read pattern:
|
||||
// 1. prepare_read() marks intent to read (also flushes outgoing)
|
||||
// 2. poll() waits for data on Wayland fd or signals
|
||||
// 3. If Wayland readable: read() consumes the guard, then dispatch_pending()
|
||||
// 4. Dropping the guard without read() cancels the prepared read
|
||||
|
||||
let read_guard = queue.prepare_read();
|
||||
|
||||
poll.poll(&mut events, Some(std::time::Duration::from_millis(100)))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("poll failed: {e}");
|
||||
running = false;
|
||||
});
|
||||
|
||||
for event in &events {
|
||||
if event.token() == TOKEN_QUIT {
|
||||
tracing::info!("Received quit signal");
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
if events.iter().any(|e| e.token() == TOKEN_WAYLAND) {
|
||||
if let Some(guard) = read_guard {
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
queue.dispatch_pending(&mut state)?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Wayland read error: {e}");
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't consume the read guard (no WAYLAND event), it drops here
|
||||
// and cancels the prepared read. That's fine — we'll retry next iteration.
|
||||
|
||||
// After dispatch, try to start a new capture frame if we're in Streaming
|
||||
// with no in-flight surface.
|
||||
state.queue_alloc_frame();
|
||||
|
||||
// Check for fatal errors from the state machine
|
||||
if state.errored {
|
||||
tracing::error!("Fatal error in state machine, exiting");
|
||||
running = false;
|
||||
}
|
||||
|
||||
// Flush outgoing Wayland messages
|
||||
conn.flush()?;
|
||||
}
|
||||
|
||||
// Clean shutdown: flush encoder and write MP4 trailer
|
||||
tracing::info!("Shutting down, flushing encoder...");
|
||||
if let crate::state::EncConstructionStage::Streaming { enc, .. } = &mut state.stage {
|
||||
if let Err(e) = enc.flush() {
|
||||
tracing::error!("Failed to flush encoder: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Done");
|
||||
Ok(())
|
||||
}
|
||||
996
src/state.rs
Normal file
996
src/state.rs
Normal file
@@ -0,0 +1,996 @@
|
||||
use std::mem;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::io::FromRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use wayland_client::globals::{GlobalList, GlobalListContents};
|
||||
use wayland_client::protocol::wl_buffer::WlBuffer;
|
||||
use wayland_client::protocol::wl_output::WlOutput;
|
||||
use wayland_client::protocol::wl_registry::WlRegistry;
|
||||
use wayland_client::{Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::{
|
||||
Event as BufferParamsEvent, Flags as BufferParamsFlags, ZwpLinuxBufferParamsV1,
|
||||
};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{
|
||||
Event as DmabufFeedbackEvent, ZwpLinuxDmabufFeedbackV1,
|
||||
};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{
|
||||
Event as DmabufEvent, ZwpLinuxDmabufV1,
|
||||
};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{
|
||||
Event as XdgOutputEvent, ZxdgOutputV1,
|
||||
};
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{
|
||||
Event as ScreencopyFrameEvent, ZwlrScreencopyFrameV1,
|
||||
};
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi as ffi;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::avhw::{AvHwDevCtx, EncState};
|
||||
use crate::cap_wlr_screencopy::CapWlrScreencopy;
|
||||
use crate::fps_limit::FpsLimit;
|
||||
use crate::transform::Transform;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CaptureSource trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Screen capture backend trait.
|
||||
pub trait CaptureSource: Sized + 'static {
|
||||
type Frame: Send;
|
||||
|
||||
fn new(
|
||||
gm: &GlobalList,
|
||||
output: &WlOutput,
|
||||
output_info: &OutputInfo,
|
||||
qh: &QueueHandle<State<Self>>,
|
||||
) -> Result<Self>;
|
||||
|
||||
fn alloc_frame(&mut self) -> Option<Self::Frame>;
|
||||
|
||||
fn queue_copy(&mut self, buffer: &WlBuffer, qh: &QueueHandle<State<Self>>);
|
||||
|
||||
fn on_done_with_frame(&mut self, frame: Self::Frame);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output info types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct OutputInfo {
|
||||
pub name: String,
|
||||
pub transform: Transform,
|
||||
pub physical_size: (i32, i32),
|
||||
pub logical_position: (i32, i32),
|
||||
}
|
||||
|
||||
pub struct PartialOutputInfo {
|
||||
pub name: Option<String>,
|
||||
pub transform: Option<Transform>,
|
||||
pub physical_size: Option<(i32, i32)>,
|
||||
pub logical_position: Option<(i32, i32)>,
|
||||
// Pixel dimensions from Mode event — preparatory for Phase 2 resolution logic
|
||||
pub mode_size: Option<(i32, i32)>,
|
||||
pub done_count: u32,
|
||||
}
|
||||
|
||||
impl Default for PartialOutputInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
transform: None,
|
||||
physical_size: None,
|
||||
logical_position: None,
|
||||
mode_size: None,
|
||||
done_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User data for XdgOutput dispatch to identify which WlOutput it belongs to.
|
||||
pub struct OutputId(pub u32);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EncConstructionStage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum EncConstructionStage<S: CaptureSource> {
|
||||
ProbingOutputs {
|
||||
outputs: Vec<PartialOutputInfo>,
|
||||
bound_outputs: Vec<WlOutput>,
|
||||
output_names: Vec<u32>,
|
||||
screencopy_manager: Option<ZwlrScreencopyManagerV1>,
|
||||
dmabuf: Option<ZwpLinuxDmabufV1>,
|
||||
dmabuf_feedback: Option<ZwpLinuxDmabufFeedbackV1>,
|
||||
xdg_output_manager: Option<ZxdgOutputManagerV1>,
|
||||
},
|
||||
EverythingButFmt {
|
||||
output_info: OutputInfo,
|
||||
output: WlOutput,
|
||||
hw_device_ctx: AvHwDevCtx,
|
||||
cap: S,
|
||||
screencopy_manager: ZwlrScreencopyManagerV1,
|
||||
dmabuf: ZwpLinuxDmabufV1,
|
||||
},
|
||||
Streaming {
|
||||
output_info: OutputInfo,
|
||||
output: WlOutput,
|
||||
enc: EncState,
|
||||
cap: S,
|
||||
screencopy_manager: ZwlrScreencopyManagerV1,
|
||||
dmabuf: ZwpLinuxDmabufV1,
|
||||
},
|
||||
Intermediate,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InFlightSurface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum InFlightSurface<S: CaptureSource> {
|
||||
None,
|
||||
AllocQueued,
|
||||
Allocd(S::Frame),
|
||||
CopyQueued {
|
||||
surface: ff::frame::Video,
|
||||
drm_map: ff::ffi::AVDRMFrameDescriptor,
|
||||
frame: S::Frame,
|
||||
buffer: WlBuffer,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct State<S: CaptureSource> {
|
||||
pub stage: EncConstructionStage<S>,
|
||||
pub in_flight_surface: InFlightSurface<S>,
|
||||
pub starting_timestamp: Option<i64>,
|
||||
pub args: Args,
|
||||
pub errored: bool,
|
||||
pub gm: GlobalList,
|
||||
pub fps_limit: FpsLimit<S::Frame>,
|
||||
pub qhandle: QueueHandle<State<S>>,
|
||||
pub drm_device: Option<PathBuf>,
|
||||
pub drm_device_from_compositor: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan /dev/dri for the first available DRM render node (renderD*).
|
||||
fn find_drm_render_node() -> Option<PathBuf> {
|
||||
std::fs::read_dir("/dev/dri").ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|s| s.starts_with("renderD"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
std::fs::metadata(&path).ok()?;
|
||||
Some(path)
|
||||
})
|
||||
.min_by_key(|e| e.to_path_buf())
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> State<S> {
|
||||
fn resolve_drm_path(&self) -> PathBuf {
|
||||
self.drm_device
|
||||
.clone()
|
||||
.or_else(|| self.drm_device_from_compositor.clone())
|
||||
.or_else(find_drm_render_node)
|
||||
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State<S> methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> State<S> {
|
||||
pub fn new(gm: GlobalList, args: Args, qhandle: QueueHandle<State<S>>) -> Self {
|
||||
let fps = args.fps;
|
||||
let drm_device = args.drm_device.as_ref().map(PathBuf::from);
|
||||
Self {
|
||||
stage: EncConstructionStage::ProbingOutputs {
|
||||
outputs: Vec::new(),
|
||||
bound_outputs: Vec::new(),
|
||||
output_names: Vec::new(),
|
||||
screencopy_manager: None,
|
||||
dmabuf: None,
|
||||
dmabuf_feedback: None,
|
||||
xdg_output_manager: None,
|
||||
},
|
||||
in_flight_surface: InFlightSurface::None,
|
||||
starting_timestamp: None,
|
||||
fps_limit: FpsLimit::new(fps),
|
||||
args,
|
||||
errored: false,
|
||||
gm,
|
||||
qhandle,
|
||||
drm_device,
|
||||
drm_device_from_compositor: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_alloc_frame(&mut self)
|
||||
where
|
||||
State<S>: Dispatch<ZwlrScreencopyFrameV1, ()>,
|
||||
{
|
||||
let (manager, output) = match &self.stage {
|
||||
EncConstructionStage::Streaming {
|
||||
screencopy_manager,
|
||||
output,
|
||||
..
|
||||
} => (screencopy_manager.clone(), output.clone()),
|
||||
EncConstructionStage::EverythingButFmt {
|
||||
screencopy_manager,
|
||||
output,
|
||||
..
|
||||
} => (screencopy_manager.clone(), output.clone()),
|
||||
_ => return,
|
||||
};
|
||||
match &self.in_flight_surface {
|
||||
InFlightSurface::None => {}
|
||||
_ => return,
|
||||
}
|
||||
let _frame_proxy = manager.capture_output(1, &output, &self.qhandle, ());
|
||||
self.in_flight_surface = InFlightSurface::AllocQueued;
|
||||
}
|
||||
|
||||
pub fn on_frame_allocd(&mut self, frame: S::Frame, format: u32, width: u32, height: u32) {
|
||||
let (frames_rgb_ctx, dmabuf, cap) = match &mut self.stage {
|
||||
EncConstructionStage::Streaming {
|
||||
output_info: _,
|
||||
output: _,
|
||||
enc,
|
||||
dmabuf,
|
||||
cap,
|
||||
screencopy_manager: _,
|
||||
} => (enc.frames_rgb().as_ptr(), dmabuf, cap),
|
||||
_ => {
|
||||
tracing::warn!("on_frame_allocd: not in Streaming stage");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut surface = ff::frame::Video::empty();
|
||||
// SAFETY: frames_rgb_ctx is a valid AVHWFramesContext pointer; surface
|
||||
// is a freshly allocated empty Video frame.
|
||||
let ret = unsafe {
|
||||
ffi::av_hwframe_get_buffer(frames_rgb_ctx, surface.as_mut_ptr(), 0)
|
||||
};
|
||||
if ret < 0 {
|
||||
tracing::error!("av_hwframe_get_buffer failed: error {}", ret);
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut map_frame = ff::frame::Video::empty();
|
||||
// SAFETY: Setting format to DRM_PRIME and calling av_hwframe_map creates
|
||||
// a mapped view of the GPU surface with DMA-BUF file descriptors.
|
||||
unsafe {
|
||||
(*map_frame.as_mut_ptr()).format =
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||
}
|
||||
let ret = unsafe { ffi::av_hwframe_map(map_frame.as_mut_ptr(), surface.as_ptr(), 0) };
|
||||
if ret < 0 {
|
||||
tracing::error!("av_hwframe_map failed: error {}", ret);
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: After av_hwframe_map with DRM_PRIME format, data[0] points to
|
||||
// a valid AVDRMFrameDescriptor.
|
||||
let desc: ff::ffi::AVDRMFrameDescriptor = unsafe {
|
||||
let desc_ptr =
|
||||
(*map_frame.as_ptr()).data[0] as *const ff::ffi::AVDRMFrameDescriptor;
|
||||
std::ptr::read(desc_ptr)
|
||||
};
|
||||
|
||||
let params = dmabuf.create_params(&self.qhandle, ());
|
||||
|
||||
for layer_idx in 0..desc.nb_layers as usize {
|
||||
let layer = &desc.layers[layer_idx];
|
||||
for p in 0..layer.nb_planes as usize {
|
||||
let plane = &layer.planes[p];
|
||||
let obj = &desc.objects[plane.object_index as usize];
|
||||
let mod_hi = (obj.format_modifier >> 32) as u32;
|
||||
let mod_lo = (obj.format_modifier & 0xFFFF_FFFF) as u32;
|
||||
// SAFETY: obj.fd is a valid DMA-BUF fd. We dup because params.add()
|
||||
// takes ownership of the fd, and the original fd is owned by map_frame.
|
||||
let fd_dup = unsafe { libc::dup(obj.fd) };
|
||||
if fd_dup < 0 {
|
||||
tracing::error!("failed to dup dma-buf fd");
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
// SAFETY: fd_dup is valid freshly-duped fd.
|
||||
let fd_owned = unsafe { OwnedFd::from_raw_fd(fd_dup) };
|
||||
params.add(
|
||||
fd_owned.as_fd(),
|
||||
p as u32,
|
||||
plane.offset as u32,
|
||||
plane.pitch as u32,
|
||||
mod_hi,
|
||||
mod_lo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let wl_buffer = params.create_immed(
|
||||
width as i32,
|
||||
height as i32,
|
||||
format,
|
||||
BufferParamsFlags::empty(),
|
||||
&self.qhandle,
|
||||
(),
|
||||
);
|
||||
self.in_flight_surface = InFlightSurface::CopyQueued {
|
||||
surface,
|
||||
drm_map: desc,
|
||||
frame,
|
||||
buffer: wl_buffer,
|
||||
};
|
||||
let buffer_ref = match &self.in_flight_surface {
|
||||
InFlightSurface::CopyQueued { buffer, .. } => buffer,
|
||||
_ => unreachable!("just set to CopyQueued"),
|
||||
};
|
||||
cap.queue_copy(buffer_ref, &self.qhandle);
|
||||
}
|
||||
|
||||
pub fn on_copy_complete(&mut self, tv_sec: u64, tv_usec: u32)
|
||||
where
|
||||
S::Frame: Default,
|
||||
{
|
||||
let (mut surface, _drm_map, frame, buffer) = match mem::replace(
|
||||
&mut self.in_flight_surface,
|
||||
InFlightSurface::None,
|
||||
) {
|
||||
InFlightSurface::CopyQueued {
|
||||
surface,
|
||||
drm_map,
|
||||
frame,
|
||||
buffer,
|
||||
} => (surface, drm_map, frame, buffer),
|
||||
other => {
|
||||
tracing::warn!("on_copy_complete: unexpected state");
|
||||
self.in_flight_surface = other;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let pts = (tv_sec as i64) * 1_000_000 + (tv_usec as i64);
|
||||
surface.set_pts(Some(pts));
|
||||
drop(buffer);
|
||||
let cap = match &mut self.stage {
|
||||
EncConstructionStage::Streaming { cap, .. } => cap,
|
||||
_ => {
|
||||
tracing::warn!("on_copy_complete: not in Streaming stage");
|
||||
return;
|
||||
}
|
||||
};
|
||||
cap.on_done_with_frame(frame);
|
||||
let enc = match &mut self.stage {
|
||||
EncConstructionStage::Streaming { enc, .. } => enc,
|
||||
_ => unreachable!("already checked Streaming above"),
|
||||
};
|
||||
let should_encode = self.fps_limit.on_new_frame(S::Frame::default(), Instant::now()).is_some();
|
||||
if should_encode {
|
||||
if let Err(e) = enc.encode_frame(&surface) {
|
||||
tracing::error!("encode_frame failed: {}", e);
|
||||
self.errored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_copy_fail(&mut self) {
|
||||
tracing::error!("compositor copy failed");
|
||||
self.errored = true;
|
||||
}
|
||||
|
||||
pub fn negotiate_format(&mut self, format: u32, width: u32, height: u32) {
|
||||
let stage_data = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
|
||||
EncConstructionStage::EverythingButFmt {
|
||||
output_info,
|
||||
output,
|
||||
hw_device_ctx: _hw_device_ctx,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
} => (output_info, output, cap, screencopy_manager, dmabuf),
|
||||
other => {
|
||||
tracing::warn!("negotiate_format: not in EverythingButFmt stage");
|
||||
self.stage = other;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (output_info, output, cap, screencopy_manager, dmabuf) = stage_data;
|
||||
let drm_path = self.resolve_drm_path();
|
||||
let bitrate = self.args.bitrate.unwrap_or_else(|| {
|
||||
let fps = self.args.fps as u64;
|
||||
2 * (width as u64) * (height as u64) * fps / 100
|
||||
});
|
||||
let gop_size = self.args.gop_size.unwrap_or(self.args.fps);
|
||||
let fps = self.args.fps;
|
||||
let enc = match EncState::new(
|
||||
&drm_path,
|
||||
Path::new(&self.args.output),
|
||||
width,
|
||||
height,
|
||||
bitrate,
|
||||
gop_size,
|
||||
fps,
|
||||
) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
tracing::error!("EncState::new failed: {}", e);
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
"Encoder initialized: {}x{} format={} bitrate={}",
|
||||
width, height, format, bitrate
|
||||
);
|
||||
self.stage = EncConstructionStage::Streaming {
|
||||
output_info,
|
||||
output,
|
||||
enc,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
};
|
||||
}
|
||||
|
||||
fn try_finalize_output(&mut self, _idx: usize) -> bool {
|
||||
let (target_idx, output_count) = match &self.stage {
|
||||
EncConstructionStage::ProbingOutputs { outputs, .. } => {
|
||||
let output_count = outputs.len();
|
||||
let idx = if let Some(ref name) = self.args.output_name {
|
||||
let pos = outputs.iter().position(|o| o.name.as_deref() == Some(name.as_str()));
|
||||
match pos {
|
||||
Some(i) => Some(i),
|
||||
None => {
|
||||
let all_probed = outputs.iter().all(|o| o.done_count >= 2);
|
||||
if all_probed {
|
||||
let available: Vec<&str> = outputs.iter()
|
||||
.filter_map(|o| o.name.as_deref())
|
||||
.collect();
|
||||
tracing::error!("Output '{}' not found. Available outputs: {:?}", name, available);
|
||||
self.errored = true;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else if outputs.iter().all(|o| o.done_count >= 2) {
|
||||
if outputs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match idx {
|
||||
Some(i) => {
|
||||
let info = &outputs[i];
|
||||
if info.done_count < 2
|
||||
|| info.name.is_none()
|
||||
|| info.transform.is_none()
|
||||
|| info.physical_size.is_none()
|
||||
|| info.logical_position.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
(i, output_count)
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let probing = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
|
||||
s @ EncConstructionStage::ProbingOutputs { .. } => s,
|
||||
other => {
|
||||
self.stage = other;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let (
|
||||
outputs,
|
||||
bound_outputs,
|
||||
_output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
_xdg_output_manager,
|
||||
) = match probing {
|
||||
EncConstructionStage::ProbingOutputs {
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
xdg_output_manager,
|
||||
} => (
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
xdg_output_manager,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// Destroy feedback object — prevents server-side resource leak
|
||||
if let Some(feedback) = dmabuf_feedback {
|
||||
feedback.destroy();
|
||||
}
|
||||
|
||||
let info = &outputs[target_idx];
|
||||
let output_info = OutputInfo {
|
||||
name: info.name.clone().unwrap(),
|
||||
transform: info.transform.unwrap(),
|
||||
physical_size: info.physical_size.unwrap(),
|
||||
logical_position: info.logical_position.unwrap(),
|
||||
};
|
||||
let output = bound_outputs[target_idx].clone();
|
||||
|
||||
let screencopy_manager = match screencopy_manager {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::error!("No screencopy manager bound");
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let dmabuf = match dmabuf {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
tracing::error!("No dmabuf manager bound");
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let drm_path = self.resolve_drm_path();
|
||||
|
||||
let hw_device_ctx = match AvHwDevCtx::new_vaapi(&drm_path) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create VAAPI device: {}", e);
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let cap = match S::new(&self.gm, &output, &output_info, &self.qhandle) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create capture source: {}", e);
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Selected output: {}", output_info.name);
|
||||
if self.args.output_name.is_none() && output_count > 1 {
|
||||
tracing::warn!("Multiple outputs found, using '{}'. Use --output-name to select.", output_info.name);
|
||||
}
|
||||
self.stage = EncConstructionStage::EverythingButFmt {
|
||||
output_info,
|
||||
output,
|
||||
hw_device_ctx,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
};
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<WlRegistry, GlobalListContents>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlRegistry, GlobalListContents> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wayland_client::protocol::wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_conn: &wayland_client::Connection,
|
||||
qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
use wayland_client::protocol::wl_registry::Event as RegistryEvent;
|
||||
|
||||
match event {
|
||||
RegistryEvent::Global { name, interface, version } => {
|
||||
match interface.as_str() {
|
||||
"zwlr_screencopy_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Binding zwlr_screencopy_manager_v1 v{v} (name={name})");
|
||||
let mgr: ZwlrScreencopyManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs { screencopy_manager, .. } = &mut state.stage {
|
||||
*screencopy_manager = Some(mgr);
|
||||
}
|
||||
}
|
||||
"zwp_linux_dmabuf_v1" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Binding zwp_linux_dmabuf_v1 v{v} (name={name})");
|
||||
let proxy: ZwpLinuxDmabufV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs { dmabuf, dmabuf_feedback, .. } = &mut state.stage {
|
||||
*dmabuf = Some(proxy.clone());
|
||||
if v >= 4 {
|
||||
let feedback = proxy.get_default_feedback(qhandle, ());
|
||||
*dmabuf_feedback = Some(feedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
"wl_output" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Binding wl_output v{v} (name={name})");
|
||||
let output: WlOutput = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
outputs, bound_outputs, output_names, xdg_output_manager, ..
|
||||
} = &mut state.stage {
|
||||
outputs.push(PartialOutputInfo::default());
|
||||
bound_outputs.push(output.clone());
|
||||
output_names.push(name);
|
||||
if let Some(xdg_mgr) = xdg_output_manager {
|
||||
let output_id = OutputId(name);
|
||||
xdg_mgr.get_xdg_output(&output, qhandle, output_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Binding zxdg_output_manager_v1 v{v} (name={name})");
|
||||
let xdg_mgr: ZxdgOutputManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
bound_outputs, xdg_output_manager, output_names, ..
|
||||
} = &mut state.stage {
|
||||
for (i, output) in bound_outputs.iter().enumerate() {
|
||||
let oname = output_names.get(i).copied().unwrap_or(0);
|
||||
let output_id = OutputId(oname);
|
||||
xdg_mgr.get_xdg_output(output, qhandle, output_id);
|
||||
}
|
||||
*xdg_output_manager = Some(xdg_mgr);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RegistryEvent::GlobalRemove { name } => {
|
||||
tracing::debug!("Global removed: name={name}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<WlOutput, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlOutput, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &WlOutput,
|
||||
event: wayland_client::protocol::wl_output::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
use wayland_client::protocol::wl_output::Event as OutputEvent;
|
||||
use wayland_client::protocol::wl_output::Mode as WlMode;
|
||||
use wayland_client::protocol::wl_output::Transform as WlTransform;
|
||||
|
||||
let idx = match &mut state.stage {
|
||||
EncConstructionStage::ProbingOutputs { outputs, .. } => {
|
||||
outputs.len().saturating_sub(1)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
match event {
|
||||
OutputEvent::Geometry { transform, physical_width, physical_height, .. } => {
|
||||
let t = match transform {
|
||||
wayland_client::WEnum::Value(WlTransform::Normal) => Transform::Normal,
|
||||
wayland_client::WEnum::Value(WlTransform::_90) => Transform::Normal90,
|
||||
wayland_client::WEnum::Value(WlTransform::_180) => Transform::Normal180,
|
||||
wayland_client::WEnum::Value(WlTransform::_270) => Transform::Normal270,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped) => Transform::Flipped,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped90) => Transform::Flipped90,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped180) => Transform::Flipped180,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped270) => Transform::Flipped270,
|
||||
_ => Transform::Normal,
|
||||
};
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.transform = Some(t);
|
||||
info.physical_size = Some((physical_width, physical_height));
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputEvent::Mode { width, height, flags, .. } => {
|
||||
let is_current = matches!(flags, wayland_client::WEnum::Value(WlMode::Current));
|
||||
if is_current {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.mode_size = Some((width, height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputEvent::Done => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.done_count += 1;
|
||||
if info.done_count >= 2 {
|
||||
state.try_finalize_output(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZxdgOutputV1, OutputId>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZxdgOutputV1, OutputId> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZxdgOutputV1,
|
||||
event: <ZxdgOutputV1 as Proxy>::Event,
|
||||
data: &OutputId,
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
let target_name = data.0;
|
||||
let idx = match &state.stage {
|
||||
EncConstructionStage::ProbingOutputs { output_names, .. } => {
|
||||
output_names.iter().position(|&n| n == target_name)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let idx = match idx {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
|
||||
match event {
|
||||
XdgOutputEvent::Name { name } => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
XdgOutputEvent::LogicalPosition { x, y } => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.logical_position = Some((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
XdgOutputEvent::LogicalSize { .. } => {}
|
||||
XdgOutputEvent::Done => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.done_count += 1;
|
||||
if info.done_count >= 2 {
|
||||
state.try_finalize_output(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZwpLinuxDmabufV1, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwpLinuxDmabufV1,
|
||||
event: <ZwpLinuxDmabufV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
DmabufEvent::Format { .. } => {}
|
||||
DmabufEvent::Modifier { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZwpLinuxDmabufFeedbackV1,
|
||||
event: <ZwpLinuxDmabufFeedbackV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
DmabufFeedbackEvent::MainDevice { device } => {
|
||||
if device.len() >= 8 {
|
||||
let dev_bytes: [u8; 8] = device[..8].try_into().unwrap_or([0u8; 8]);
|
||||
let dev_t = u64::from_ne_bytes(dev_bytes);
|
||||
let minor = (dev_t as u32) & 0xFFFFF;
|
||||
let path = PathBuf::from(format!("/dev/dri/renderD{}", minor));
|
||||
if path.exists() {
|
||||
tracing::info!("Compositor DRM device: {} (dev_t: {})", path.display(), dev_t);
|
||||
state.drm_device_from_compositor = Some(path);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Compositor reported DRM device {} (dev_t: {}) but path does not exist",
|
||||
path.display(), dev_t
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("main_device event with unexpected data length: {}", device.len());
|
||||
}
|
||||
}
|
||||
DmabufFeedbackEvent::FormatTable { .. } => {}
|
||||
DmabufFeedbackEvent::Done => {}
|
||||
DmabufFeedbackEvent::TrancheDone => {}
|
||||
DmabufFeedbackEvent::TrancheTargetDevice { .. } => {}
|
||||
DmabufFeedbackEvent::TrancheFormats { .. } => {}
|
||||
DmabufFeedbackEvent::TrancheFlags { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZwpLinuxBufferParamsV1, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxBufferParamsV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwpLinuxBufferParamsV1,
|
||||
event: <ZwpLinuxBufferParamsV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
BufferParamsEvent::Created { .. } => {
|
||||
tracing::debug!("DMA-BUF buffer created");
|
||||
}
|
||||
BufferParamsEvent::Failed => {
|
||||
tracing::error!("DMA-BUF buffer creation failed");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZwlrScreencopyFrameV1, ()> for CapWlrScreencopy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Dispatch<ZwlrScreencopyFrameV1, ()> for State<CapWlrScreencopy> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ZwlrScreencopyFrameV1,
|
||||
event: <ZwlrScreencopyFrameV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<CapWlrScreencopy>>,
|
||||
) {
|
||||
match event {
|
||||
ScreencopyFrameEvent::Buffer { .. } => {
|
||||
tracing::warn!("Received SHM Buffer event — only DMA-BUF capture is supported. Ignoring.");
|
||||
proxy.destroy();
|
||||
state.errored = true;
|
||||
return;
|
||||
}
|
||||
ScreencopyFrameEvent::LinuxDmabuf { format, width, height } => {
|
||||
tracing::debug!("Screencopy LinuxDmabuf: format={format}, {width}x{height}");
|
||||
if matches!(state.stage, EncConstructionStage::EverythingButFmt { .. }) {
|
||||
state.negotiate_format(format, width, height);
|
||||
if state.errored {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
|
||||
cap.current_frame = Some(proxy.clone());
|
||||
}
|
||||
state.on_frame_allocd((), format, width, height);
|
||||
}
|
||||
ScreencopyFrameEvent::Ready { tv_sec_hi, tv_sec_lo, tv_nsec } => {
|
||||
let tv_sec = (tv_sec_hi as u64) << 32 | tv_sec_lo as u64;
|
||||
let tv_usec = tv_nsec / 1000;
|
||||
tracing::trace!("Screencopy ready: tv_sec={tv_sec}, tv_usec={tv_usec}");
|
||||
state.on_copy_complete(tv_sec, tv_usec);
|
||||
}
|
||||
ScreencopyFrameEvent::Failed => {
|
||||
tracing::error!("Screencopy frame failed");
|
||||
state.on_copy_fail();
|
||||
}
|
||||
ScreencopyFrameEvent::Damage { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZxdgOutputManagerV1, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZxdgOutputManagerV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZxdgOutputManagerV1,
|
||||
_event: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<ZwlrScreencopyManagerV1, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwlrScreencopyManagerV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwlrScreencopyManagerV1,
|
||||
_event: <ZwlrScreencopyManagerV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch<WlBuffer, ()>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlBuffer, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &WlBuffer,
|
||||
event: <WlBuffer as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
if let wayland_client::protocol::wl_buffer::Event::Release = event {
|
||||
tracing::trace!("WlBuffer released");
|
||||
}
|
||||
}
|
||||
}
|
||||
291
src/transform.rs
Normal file
291
src/transform.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
/// Coordinate transformation module for Wayland output transforms.
|
||||
///
|
||||
/// Handles the 8 `wl_output` transform variants (rotation + reflection)
|
||||
/// and ROI clipping for screen capture.
|
||||
///
|
||||
/// Wayland output transform enum, matching `wl_output::Transform`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Transform {
|
||||
Normal,
|
||||
Normal90,
|
||||
Normal180,
|
||||
Normal270,
|
||||
Flipped,
|
||||
Flipped90,
|
||||
Flipped180,
|
||||
Flipped270,
|
||||
}
|
||||
|
||||
/// Axis-aligned rectangle in integer coordinates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
/// Returns the 2×2 basis matrix (a, b, c, d) for the given transform.
|
||||
///
|
||||
/// The matrix represents the affine mapping from screen coordinates to
|
||||
/// frame coordinates:
|
||||
///
|
||||
/// ```text
|
||||
/// [new_x] [a b] [x]
|
||||
/// [new_y] = [c d] [y]
|
||||
/// ```
|
||||
pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
|
||||
match transform {
|
||||
Transform::Normal => (1, 0, 0, 1),
|
||||
Transform::Normal90 => (0, 1, -1, 0),
|
||||
Transform::Normal180 => (-1, 0, 0, -1),
|
||||
Transform::Normal270 => (0, -1, 1, 0),
|
||||
Transform::Flipped => (-1, 0, 0, 1),
|
||||
Transform::Flipped90 => (0, 1, 1, 0),
|
||||
Transform::Flipped180 => (1, 0, 0, -1),
|
||||
Transform::Flipped270 => (0, -1, -1, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform a rectangle from screen space to frame space.
|
||||
///
|
||||
/// Applies the 2×2 basis matrix and computes offsets so the result
|
||||
/// fits within the frame dimensions `(frame_w, frame_h)`.
|
||||
///
|
||||
/// ```text
|
||||
/// new_x = a * x + b * y + offset_x
|
||||
/// new_y = c * x + d * y + offset_y
|
||||
/// ```
|
||||
pub fn screen_to_frame(
|
||||
transform: Transform,
|
||||
rect: Rect,
|
||||
frame_w: i32,
|
||||
frame_h: i32,
|
||||
) -> Rect {
|
||||
let (a, b, c, d) = transform_basis(transform);
|
||||
|
||||
// Compute the offset so that the transformed origin maps correctly.
|
||||
// For transforms with negative components, we need to shift by the
|
||||
// frame dimension to keep coordinates in [0, frame_w) × [0, frame_h).
|
||||
let offset_x = if a + b < 0 { frame_w } else { 0 };
|
||||
let offset_y = if c + d < 0 { frame_h } else { 0 };
|
||||
|
||||
let new_x = a * rect.x + b * rect.y + offset_x;
|
||||
let new_y = c * rect.x + d * rect.y + offset_y;
|
||||
let new_w = a * rect.w + b * rect.h;
|
||||
let new_h = c * rect.w + d * rect.h;
|
||||
|
||||
Rect {
|
||||
x: new_x,
|
||||
y: new_y,
|
||||
w: new_w.abs(),
|
||||
h: new_h.abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap width and height for 90° or 270° rotations.
|
||||
///
|
||||
/// After a quarter-turn rotation the output dimensions are transposed
|
||||
/// relative to the input. This helper returns `(h, w)` for those cases
|
||||
/// and `(w, h)` unchanged otherwise.
|
||||
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
|
||||
match transform {
|
||||
Transform::Normal90 | Transform::Normal270 | Transform::Flipped90 | Transform::Flipped270 => {
|
||||
(h, w)
|
||||
}
|
||||
_ => (w, h),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a rectangle so it stays inside `(0, 0) .. (bounds_w, bounds_h)`.
|
||||
///
|
||||
/// The resulting rectangle has non-negative origin and its extent does
|
||||
/// not exceed the bounds.
|
||||
pub fn fit_inside_bounds(rect: Rect, bounds_w: i32, bounds_h: i32) -> Rect {
|
||||
let x = rect.x.clamp(0, bounds_w);
|
||||
let y = rect.y.clamp(0, bounds_h);
|
||||
let right = (rect.x + rect.w).min(bounds_w);
|
||||
let bottom = (rect.y + rect.h).min(bounds_h);
|
||||
let w = (right - x).max(0);
|
||||
let h = (bottom - y).max(0);
|
||||
Rect { x, y, w, h }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── transform_basis ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn basis_normal_is_identity() {
|
||||
assert_eq!(transform_basis(Transform::Normal), (1, 0, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_90_cw_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal90), (0, 1, -1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_180_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal180), (-1, 0, 0, -1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_270_cw_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal270), (0, -1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_horizontal() {
|
||||
assert_eq!(transform_basis(Transform::Flipped), (-1, 0, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_90() {
|
||||
assert_eq!(transform_basis(Transform::Flipped90), (0, 1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_180() {
|
||||
assert_eq!(transform_basis(Transform::Flipped180), (1, 0, 0, -1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_270() {
|
||||
assert_eq!(transform_basis(Transform::Flipped270), (0, -1, -1, 0));
|
||||
}
|
||||
|
||||
// ── screen_to_frame ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_identity_unchanged() {
|
||||
let rect = Rect { x: 10, y: 20, w: 100, h: 50 };
|
||||
let result = screen_to_frame(Transform::Normal, rect, 1920, 1080);
|
||||
assert_eq!(result, Rect { x: 10, y: 20, w: 100, h: 50 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_90_rotates_origin() {
|
||||
// 90° CW: top-left (0,0) in screen should map to bottom-left in frame
|
||||
let rect = Rect { x: 0, y: 0, w: 100, h: 50 };
|
||||
let result = screen_to_frame(Transform::Normal90, rect, 1080, 1920);
|
||||
// a=0,b=1,c=-1,d=0 => offset_x=0, offset_y=1920 (c+d=-1<0)
|
||||
// new_x = 0*0 + 1*0 + 0 = 0
|
||||
// new_y = -1*0 + 0*0 + 1920 = 1920
|
||||
assert_eq!(result.x, 0);
|
||||
assert_eq!(result.y, 1920);
|
||||
// w' = 0*100 + 1*50 = 50, h' = -1*100 + 0*50 = -100 -> abs=100
|
||||
assert_eq!(result.w, 50);
|
||||
assert_eq!(result.h, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_180_rotates() {
|
||||
let rect = Rect { x: 100, y: 200, w: 300, h: 400 };
|
||||
let result = screen_to_frame(Transform::Normal180, rect, 1920, 1080);
|
||||
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
|
||||
assert_eq!(result.x, -100 + 1920);
|
||||
assert_eq!(result.y, -200 + 1080);
|
||||
assert_eq!(result.w, 300);
|
||||
assert_eq!(result.h, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_flipped_horizontal() {
|
||||
let rect = Rect { x: 50, y: 30, w: 200, h: 100 };
|
||||
let result = screen_to_frame(Transform::Flipped, rect, 1920, 1080);
|
||||
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
|
||||
assert_eq!(result.x, -50 + 1920);
|
||||
assert_eq!(result.y, 30);
|
||||
assert_eq!(result.w, 200);
|
||||
assert_eq!(result.h, 100);
|
||||
}
|
||||
|
||||
// ── transpose_if_transform_transposed ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn transpose_normal_no_swap() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Normal, 1920, 1080), (1920, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_90_swaps() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Normal90, 1920, 1080), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_180_no_swap() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Normal180, 1920, 1080), (1920, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_270_swaps() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Normal270, 1920, 1080), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_flipped_no_swap() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Flipped, 1920, 1080), (1920, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_flipped90_swaps() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Flipped90, 1920, 1080), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_flipped180_no_swap() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Flipped180, 1920, 1080), (1920, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_flipped270_swaps() {
|
||||
assert_eq!(transpose_if_transform_transposed(Transform::Flipped270, 1920, 1080), (1080, 1920));
|
||||
}
|
||||
|
||||
// ── fit_inside_bounds ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fit_inside_already_fits() {
|
||||
let rect = Rect { x: 10, y: 20, w: 100, h: 50 };
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_clips_right_and_bottom() {
|
||||
let rect = Rect { x: 1800, y: 1000, w: 200, h: 200 };
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, Rect { x: 1800, y: 1000, w: 120, h: 80 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_clips_negative_origin() {
|
||||
let rect = Rect { x: -50, y: -30, w: 200, h: 200 };
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, Rect { x: 0, y: 0, w: 150, h: 170 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_completely_out_of_bounds() {
|
||||
let rect = Rect { x: 2000, y: 2000, w: 100, h: 100 };
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, Rect { x: 1920, y: 1080, w: 0, h: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_zero_size_rect() {
|
||||
let rect = Rect { x: 100, y: 100, w: 0, h: 0 };
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, Rect { x: 100, y: 100, w: 0, h: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_zero_bounds() {
|
||||
let rect = Rect { x: 0, y: 0, w: 100, h: 100 };
|
||||
let result = fit_inside_bounds(rect, 0, 0);
|
||||
assert_eq!(result, Rect { x: 0, y: 0, w: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user