visual-cortex-capture 0.1.0

Screen-capture abstraction for visual-cortex: frames, regions, rates, the FrameSource trait, and the macOS ScreenCaptureKit backend.
Documentation
use std::sync::Arc;

use scap::capturer::{Capturer, Options};
use scap::frame::{Frame as ScapFrame, FrameType};
use scap::Target as ScapTarget;

use crate::error::CaptureError;
use crate::frame::Frame;
use crate::source::FrameSource;
use crate::target::Target;

/// Frames-per-second requested from `scap`. The session throttles evaluation
/// independently; this only bounds how fresh the backing capture can be.
const CAPTURE_FPS: u32 = 60;

/// Real screen capture on macOS via the `scap` crate (ScreenCaptureKit),
/// behind our `FrameSource` trait.
pub struct ScapSource {
    capturer: Capturer,
    target: Target,
    reattach: bool,
}

impl ScapSource {
    /// Resolve `target`, check the Screen Recording permission, and start a
    /// capturer. If `reattach` is set, a lost target is re-resolved on the fly.
    pub fn new(target: &Target, reattach: bool) -> Result<Self, CaptureError> {
        if !scap::is_supported() {
            return Err(CaptureError::Backend("scap: platform not supported".into()));
        }
        if !scap::has_permission() && !scap::request_permission() {
            return Err(CaptureError::PermissionDenied(
                "grant Screen Recording in System Settings › Privacy & Security".into(),
            ));
        }
        let capturer = build_capturer(target)?;
        Ok(Self {
            capturer,
            target: target.clone(),
            reattach,
        })
    }
}

/// Map our `Target` onto a live `scap::Target`, or `TargetNotFound`.
fn resolve_scap_target(target: &Target) -> Result<ScapTarget, CaptureError> {
    let all = scap::get_all_targets();
    match target {
        Target::Display(index) => all
            .into_iter()
            .filter(|t| matches!(t, ScapTarget::Display(_)))
            .nth(*index)
            .ok_or_else(|| CaptureError::TargetNotFound(format!("display index {index}"))),
        Target::WindowTitled(title) => all
            .into_iter()
            .find(|t| matches!(t, ScapTarget::Window(w) if w.title == *title))
            .ok_or_else(|| CaptureError::TargetNotFound(format!("window titled {title:?}"))),
    }
}

/// Resolve the target and start a fresh BGRA capturer for it.
fn build_capturer(target: &Target) -> Result<Capturer, CaptureError> {
    let scap_target = resolve_scap_target(target)?;
    let options = Options {
        fps: CAPTURE_FPS,
        target: Some(scap_target),
        output_type: FrameType::BGRAFrame,
        show_cursor: true,
        ..Default::default()
    };
    let mut capturer = Capturer::build(options)
        .map_err(|e| CaptureError::Backend(format!("scap build: {e:?}")))?;
    capturer.start_capture();
    Ok(capturer)
}

/// Convert a `scap` BGRA frame into our BGRA8 `Frame`. Pure and platform-free
/// so it is unit-tested; `Frame::new` validates the byte length.
fn video_bgra_to_frame(width: i32, height: i32, data: Vec<u8>) -> Result<Arc<Frame>, CaptureError> {
    if width <= 0 || height <= 0 {
        return Err(CaptureError::Backend(format!(
            "non-positive frame dimensions {width}x{height}"
        )));
    }
    Frame::new(width as u32, height as u32, data).map(Arc::new)
}

#[async_trait::async_trait]
impl FrameSource for ScapSource {
    async fn capture_frame(&mut self) -> Result<Arc<Frame>, CaptureError> {
        // scap 0.0.8 uses a flat `Frame` enum (no Audio/Video split); a BGRA
        // capturer yields `Frame::BGRA`.
        match self.capturer.get_next_frame() {
            Ok(ScapFrame::BGRA(f)) => video_bgra_to_frame(f.width, f.height, f.data),
            Ok(_) => Err(CaptureError::Backend(
                "scap returned a non-BGRA frame; expected FrameType::BGRAFrame".into(),
            )),
            Err(_) => {
                // The capture channel closed: the target is gone. Optionally try
                // to rebuild the capturer so the next tick can reattach.
                if self.reattach {
                    if let Ok(capturer) = build_capturer(&self.target) {
                        self.capturer = capturer;
                    }
                }
                Err(CaptureError::TargetLost)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn converts_bgra_dimensions_and_rejects_bad_input() {
        let good = video_bgra_to_frame(2, 2, vec![9u8; 16]).unwrap();
        assert_eq!((good.width(), good.height()), (2, 2));
        // Non-positive dimensions and length/stride mismatches must error, not panic.
        assert!(video_bgra_to_frame(-1, 2, vec![]).is_err());
        assert!(video_bgra_to_frame(2, 2, vec![0u8; 15]).is_err());
    }
}