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:
dailz
2026-04-05 23:35:00 +08:00
commit 6d49222de8
17 changed files with 6964 additions and 0 deletions

45
src/args.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 });
}
}