Files
fluxer/fluxer_desktop/native/win-game-capture/src/lib.rs
T

1093 lines
34 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::all)]
#![allow(unsafe_op_in_unsafe_fn)]
#[cfg(target_os = "windows")]
mod capture_target;
#[cfg(target_os = "windows")]
mod d3d11_device;
#[cfg(any(target_os = "windows", test))]
mod dxgi_capture;
pub mod encoder_attach;
mod fallback;
mod game_capture_abi;
mod gpu_priority;
mod hdr;
#[cfg(target_os = "windows")]
mod nv12_gpu;
mod sources;
#[cfg(any(target_os = "windows", test))]
mod stall;
#[cfg(target_os = "windows")]
mod wgc_capture;
pub use encoder_attach::{EncoderAttachError, EncoderAttachStats, EncoderAttachment};
use napi::bindgen_prelude::*;
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi::{JsValue, Status, ValueType};
use napi_derive::napi;
use parking_lot::{Mutex, RwLock};
use std::ffi::c_void;
use std::sync::Arc;
#[cfg(target_os = "windows")]
use dxgi_capture::DxgiCaptureSession;
use fluxer_encoder_ring::EncoderFrameRate;
#[cfg(target_os = "windows")]
use fluxer_screen_frame_bus::EnqueueOutcome;
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(target_os = "windows")]
use wgc_capture::WgcCaptureSession;
const LIFECYCLE_QUEUE_LIMIT: usize = 8;
const START_OPTION_UNSUPPORTED_LIMIT: usize = 4;
type LifecycleTsfn = Arc<
ThreadsafeFunction<
(String, String),
(),
(String, String),
napi::Status,
false,
true,
LIFECYCLE_QUEUE_LIMIT,
>,
>;
#[napi(object)]
#[derive(Clone, Debug)]
pub struct ScreenCaptureRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[napi(object)]
#[derive(Clone, Debug, Default)]
pub struct ScreenCaptureStartOptions {
#[napi(js_name = "showCursorClicks")]
pub show_cursor_clicks: Option<bool>,
#[napi(js_name = "captureRect")]
pub capture_rect: Option<ScreenCaptureRect>,
#[napi(js_name = "colorRange")]
pub color_range: Option<String>,
#[napi(js_name = "colorSpace")]
pub color_space: Option<String>,
}
#[napi(object)]
#[derive(Clone, Debug, Default)]
pub struct CaptureStartOptionsDiagnostics {
#[napi(js_name = "showCursorClicks")]
pub show_cursor_clicks: Option<bool>,
#[napi(js_name = "captureRect")]
pub capture_rect: Option<ScreenCaptureRect>,
#[napi(js_name = "colorRange")]
pub color_range: Option<String>,
#[napi(js_name = "colorSpace")]
pub color_space: Option<String>,
#[napi(js_name = "unsupportedOptions")]
pub unsupported_options: Vec<String>,
}
#[napi(object)]
pub struct CaptureStartResult {
pub width: u32,
pub height: u32,
#[napi(js_name = "frameRate")]
pub frame_rate: u32,
#[napi(js_name = "pixelFormat")]
pub pixel_format: String,
}
#[napi(object)]
pub struct ScreenCaptureSourceDescriptor {
pub kind: String,
pub id: String,
pub name: String,
pub width: u32,
pub height: u32,
#[napi(js_name = "targetPid")]
pub target_pid: Option<u32>,
}
#[napi(object)]
pub struct AvailabilityInfo {
pub available: bool,
pub backend: String,
pub reason: Option<String>,
}
#[napi(object)]
pub struct CaptureDiagnostics {
pub state: u32,
#[napi(js_name = "apiType")]
pub api_type: u32,
pub transport: u32,
#[napi(js_name = "fallbackReason")]
pub fallback_reason: u32,
#[napi(js_name = "captureFlags")]
pub capture_flags: u32,
pub width: u32,
pub height: u32,
#[napi(js_name = "dxgiFormat")]
pub dxgi_format: u32,
#[napi(js_name = "frameCounter")]
pub frame_counter: f64,
#[napi(js_name = "droppedFrameCounter")]
pub dropped_frame_counter: f64,
#[napi(js_name = "lastPresentTimestampUs")]
pub last_present_timestamp_us: f64,
#[napi(js_name = "lastError")]
pub last_error: u32,
#[napi(js_name = "activeStrategy")]
pub active_strategy: String,
#[napi(js_name = "lastFallbackReason")]
pub last_fallback_reason: String,
#[napi(js_name = "startOptions")]
pub start_options: CaptureStartOptionsDiagnostics,
#[napi(js_name = "frameSinkAccepted")]
pub frame_sink_accepted: f64,
#[napi(js_name = "frameSinkCoalesced")]
pub frame_sink_coalesced: f64,
#[napi(js_name = "frameSinkRejected")]
pub frame_sink_rejected: f64,
#[napi(js_name = "mediaFramesDroppedWithoutSink")]
pub media_frames_dropped_without_sink: f64,
#[napi(js_name = "cpuFallbackFramesDropped")]
pub cpu_fallback_frames_dropped: f64,
}
#[napi(object)]
pub struct EncoderAttachDiagnostics {
pub attached: bool,
pub width: u32,
pub height: u32,
pub capacity: u32,
#[napi(js_name = "framesSubmitted")]
pub frames_submitted: f64,
#[napi(js_name = "framesDropped")]
pub frames_dropped: f64,
#[napi(js_name = "ringFullEvents")]
pub ring_full_events: f64,
#[napi(js_name = "failedBlits")]
pub failed_blits: f64,
}
#[napi(object)]
pub struct FrameSinkDiagnostics {
pub accepted: f64,
pub coalesced: f64,
pub rejected: f64,
#[napi(js_name = "mediaFramesDroppedWithoutSink")]
pub media_frames_dropped_without_sink: f64,
#[napi(js_name = "cpuFallbackFramesDropped")]
pub cpu_fallback_frames_dropped: f64,
}
pub struct CaptureInner {
pub lifecycle_tsfn: Mutex<Option<LifecycleTsfn>>,
#[cfg(target_os = "windows")]
pub session: Mutex<Option<DxgiCaptureSession>>,
#[cfg(target_os = "windows")]
pub(crate) wgc_session: Mutex<Option<WgcCaptureSession>>,
pub running: std::sync::atomic::AtomicBool,
pub fallback: Mutex<Option<fallback::FallbackTracker>>,
pub capture_id: Mutex<Option<String>>,
pub start_options: Mutex<CaptureStartOptionsDiagnostics>,
pub encoder_attachment: RwLock<Option<Arc<EncoderAttachment>>>,
pub native_frame_sink:
Mutex<Option<Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>>>,
#[cfg(target_os = "windows")]
pub frame_sink_accepted: AtomicU64,
#[cfg(target_os = "windows")]
pub frame_sink_coalesced: AtomicU64,
#[cfg(target_os = "windows")]
pub frame_sink_rejected: AtomicU64,
#[cfg(target_os = "windows")]
pub media_frames_dropped_without_sink: AtomicU64,
#[cfg(target_os = "windows")]
pub cpu_fallback_frames_dropped: AtomicU64,
#[cfg(target_os = "windows")]
pub frame_sink_backpressure_emitted: AtomicBool,
#[cfg(target_os = "windows")]
pub frame_sink_missing_emitted: AtomicBool,
#[cfg(target_os = "windows")]
pub cpu_fallback_emitted: AtomicBool,
}
pub fn emit_lifecycle(inner: &CaptureInner, event_type: &str, message: &str) {
let guard = inner.lifecycle_tsfn.lock();
if let Some(tsfn) = guard.as_ref() {
let _ = tsfn.call(
(event_type.to_string(), message.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
#[cfg(target_os = "windows")]
struct BusSharedTexture {
handle: u64,
width: u32,
height: u32,
dxgi_format: u32,
timestamp_us: i64,
}
#[cfg(target_os = "windows")]
impl BusSharedTexture {
fn into_bus_desc(self) -> fluxer_screen_frame_bus::SharedTextureDesc {
fluxer_screen_frame_bus::SharedTextureDesc {
handle: self.handle,
width: self.width,
height: self.height,
dxgi_format: self.dxgi_format,
timestamp_us: self.timestamp_us,
}
}
}
#[derive(Clone, Copy)]
struct FrameSinkCounterSnapshot {
accepted: u64,
coalesced: u64,
rejected: u64,
dropped_without_sink: u64,
cpu_fallback_dropped: u64,
}
#[cfg(target_os = "windows")]
fn frame_sink_counter_snapshot(inner: &CaptureInner) -> FrameSinkCounterSnapshot {
FrameSinkCounterSnapshot {
accepted: inner.frame_sink_accepted.load(Ordering::Acquire),
coalesced: inner.frame_sink_coalesced.load(Ordering::Acquire),
rejected: inner.frame_sink_rejected.load(Ordering::Acquire),
dropped_without_sink: inner
.media_frames_dropped_without_sink
.load(Ordering::Acquire),
cpu_fallback_dropped: inner.cpu_fallback_frames_dropped.load(Ordering::Acquire),
}
}
#[cfg(target_os = "windows")]
fn frame_sink_diagnostics_from(snapshot: FrameSinkCounterSnapshot) -> FrameSinkDiagnostics {
FrameSinkDiagnostics {
accepted: snapshot.accepted as f64,
coalesced: snapshot.coalesced as f64,
rejected: snapshot.rejected as f64,
media_frames_dropped_without_sink: snapshot.dropped_without_sink as f64,
cpu_fallback_frames_dropped: snapshot.cpu_fallback_dropped as f64,
}
}
#[cfg(target_os = "windows")]
pub(crate) enum FrameSinkRef {
Native(Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>),
Bus(Arc<dyn fluxer_screen_frame_bus::ScreenFrameSink>),
}
#[cfg(target_os = "windows")]
pub(crate) fn resolve_frame_sink(
inner: &CaptureInner,
capture_id: Option<&str>,
) -> Option<FrameSinkRef> {
if let Some(sink) = native_frame_sink_for(inner) {
return Some(FrameSinkRef::Native(sink));
}
let capture_id = capture_id?;
fluxer_screen_frame_bus::get_sink(capture_id).map(FrameSinkRef::Bus)
}
#[cfg(target_os = "windows")]
pub(crate) fn emit_shared_texture_frame(
inner: &CaptureInner,
sink: &FrameSinkRef,
handle: u64,
width: u32,
height: u32,
dxgi_format: u32,
timestamp_us: i64,
) -> bool {
assert!(handle != 0, "shared texture handle is non-zero");
assert!(width > 0, "shared texture width is positive");
assert!(height > 0, "shared texture height is positive");
let desc = BusSharedTexture {
handle,
width,
height,
dxgi_format,
timestamp_us,
}
.into_bus_desc();
let outcome = match sink {
FrameSinkRef::Native(sink) => sink.enqueue_shared_texture(desc),
FrameSinkRef::Bus(sink) => {
sink.enqueue(fluxer_screen_frame_bus::ScreenFrame::SharedTexture(desc))
}
};
record_frame_sink_outcome(inner, outcome);
frame_sink_outcome_delivered(outcome)
}
#[cfg(target_os = "windows")]
fn frame_sink_outcome_delivered(outcome: EnqueueOutcome) -> bool {
!matches!(outcome, EnqueueOutcome::Rejected)
}
#[cfg(target_os = "windows")]
fn record_frame_sink_outcome(inner: &CaptureInner, outcome: EnqueueOutcome) {
match outcome {
EnqueueOutcome::Accepted => {
inner.frame_sink_accepted.fetch_add(1, Ordering::AcqRel);
}
EnqueueOutcome::Coalesced => {
inner.frame_sink_coalesced.fetch_add(1, Ordering::AcqRel);
emit_frame_sink_backpressure_once(
inner,
"Windows shared texture frame coalesced by native frame sink",
);
}
EnqueueOutcome::Rejected => {
inner.frame_sink_rejected.fetch_add(1, Ordering::AcqRel);
emit_frame_sink_backpressure_once(
inner,
"Windows shared texture frame rejected by native frame sink",
);
}
}
}
#[cfg(target_os = "windows")]
fn emit_frame_sink_backpressure_once(inner: &CaptureInner, message: &'static str) {
if inner
.frame_sink_backpressure_emitted
.swap(true, Ordering::AcqRel)
{
return;
}
emit_lifecycle(inner, "diagnostic", message);
}
#[cfg(target_os = "windows")]
pub(crate) fn note_media_frame_without_sink(inner: &CaptureInner, message: &'static str) {
inner
.media_frames_dropped_without_sink
.fetch_add(1, Ordering::AcqRel);
if inner
.frame_sink_missing_emitted
.swap(true, Ordering::AcqRel)
{
return;
}
emit_lifecycle(inner, "diagnostic", message);
}
#[cfg(target_os = "windows")]
fn native_frame_sink_for(
inner: &CaptureInner,
) -> Option<Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>> {
inner.native_frame_sink.lock().as_ref().cloned()
}
pub fn observe_fallback(
inner: &CaptureInner,
signature: fallback::FailureSignature,
) -> Option<fallback::FallbackDecision> {
let decision = {
let mut guard = inner.fallback.lock();
guard.as_mut().map(|tracker| tracker.observe(signature))
};
if let Some(decision) = decision.as_ref() {
let (kind, message) = fallback::decision_lifecycle(decision);
emit_lifecycle(inner, kind, &message);
}
decision
}
#[napi]
pub struct ScreenCapture {
inner: Arc<CaptureInner>,
}
#[napi]
impl ScreenCapture {
#[allow(clippy::new_without_default)]
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: Arc::new(CaptureInner {
lifecycle_tsfn: Mutex::new(None),
#[cfg(target_os = "windows")]
session: Mutex::new(None),
#[cfg(target_os = "windows")]
wgc_session: Mutex::new(None),
running: std::sync::atomic::AtomicBool::new(false),
fallback: Mutex::new(None),
capture_id: Mutex::new(None),
start_options: Mutex::new(CaptureStartOptionsDiagnostics::default()),
encoder_attachment: RwLock::new(None),
native_frame_sink: Mutex::new(None),
#[cfg(target_os = "windows")]
frame_sink_accepted: AtomicU64::new(0),
#[cfg(target_os = "windows")]
frame_sink_coalesced: AtomicU64::new(0),
#[cfg(target_os = "windows")]
frame_sink_rejected: AtomicU64::new(0),
#[cfg(target_os = "windows")]
media_frames_dropped_without_sink: AtomicU64::new(0),
#[cfg(target_os = "windows")]
cpu_fallback_frames_dropped: AtomicU64::new(0),
#[cfg(target_os = "windows")]
frame_sink_backpressure_emitted: AtomicBool::new(false),
#[cfg(target_os = "windows")]
frame_sink_missing_emitted: AtomicBool::new(false),
#[cfg(target_os = "windows")]
cpu_fallback_emitted: AtomicBool::new(false),
}),
}
}
#[napi(js_name = "setLifecycleCallback")]
pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> {
let tsfn: LifecycleTsfn = callback
.build_threadsafe_function::<(String, String)>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<LIFECYCLE_QUEUE_LIMIT>()
.build()
.map(Arc::new)?;
let mut guard = self.inner.lifecycle_tsfn.lock();
*guard = Some(tsfn);
Ok(())
}
#[napi(js_name = "setFrameSinkHandle")]
pub fn set_frame_sink_handle(&self, frame_sink_handle: Unknown<'_>) -> Result<()> {
let sink = retain_native_frame_sink_handle(frame_sink_handle)?;
let mut guard = self.inner.native_frame_sink.lock();
*guard = Some(sink);
Ok(())
}
#[napi]
#[allow(clippy::too_many_arguments)]
pub fn start(
&self,
source_id: String,
source_kind: String,
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<u32>,
capture_id: Option<String>,
start_options: Option<ScreenCaptureStartOptions>,
) -> Result<CaptureStartResult> {
let start_options = record_start_options(&self.inner, start_options)?;
let normalized_capture_id = capture_id
.map(|raw| raw.trim().to_string())
.filter(|trimmed| !trimmed.is_empty());
{
let mut guard = self.inner.capture_id.lock();
*guard = normalized_capture_id;
}
#[cfg(target_os = "windows")]
{
self.start_windows(
source_id,
source_kind,
width,
height,
frame_rate,
start_options,
)
}
#[cfg(not(target_os = "windows"))]
{
let _ = (
source_id,
source_kind,
width,
height,
frame_rate,
start_options,
);
Err(napi::Error::from_reason(
"native game capture only supported on Windows",
))
}
}
#[napi(js_name = "getDiagnostics")]
pub fn get_diagnostics(&self) -> Option<CaptureDiagnostics> {
let snapshot = {
let guard = self.inner.fallback.lock();
guard.as_ref().map(|tracker| tracker.snapshot())
}?;
#[cfg(target_os = "windows")]
{
let frame_sink = frame_sink_counter_snapshot(&self.inner);
Some(strategy_only_diagnostics(
&snapshot,
current_start_options(&self.inner),
frame_sink,
))
}
#[cfg(not(target_os = "windows"))]
Some(strategy_only_diagnostics(
&snapshot,
current_start_options(&self.inner),
FrameSinkCounterSnapshot {
accepted: 0,
coalesced: 0,
rejected: 0,
dropped_without_sink: 0,
cpu_fallback_dropped: 0,
},
))
}
#[napi(js_name = "getFrameSinkDiagnostics")]
pub fn get_frame_sink_diagnostics(&self) -> FrameSinkDiagnostics {
#[cfg(target_os = "windows")]
{
frame_sink_diagnostics_from(frame_sink_counter_snapshot(&self.inner))
}
#[cfg(not(target_os = "windows"))]
{
FrameSinkDiagnostics {
accepted: 0.0,
coalesced: 0.0,
rejected: 0.0,
media_frames_dropped_without_sink: 0.0,
cpu_fallback_frames_dropped: 0.0,
}
}
}
#[napi]
pub fn stop(&self) -> Result<()> {
self.inner
.running
.store(false, std::sync::atomic::Ordering::Release);
self.inner.capture_id.lock().take();
self.inner.native_frame_sink.lock().take();
if let Some(attachment) = self.inner.encoder_attachment.write().take() {
attachment.detach();
}
#[cfg(target_os = "windows")]
{
let mut guard = self.inner.session.lock();
*guard = None;
let mut wgc_guard = self.inner.wgc_session.lock();
*wgc_guard = None;
}
{
let mut fallback_guard = self.inner.fallback.lock();
*fallback_guard = None;
}
Ok(())
}
#[napi(js_name = "attachEncoder")]
pub fn attach_encoder(&self, width: u32, height: u32, frame_rate: Option<u32>) -> Result<()> {
if width == 0 || height == 0 {
return Err(napi::Error::new(
Status::InvalidArg,
"ScreenCapture.attachEncoder requires positive dimensions",
));
}
let frame_rate = EncoderFrameRate::from_fps(frame_rate.unwrap_or(30));
let attachment = EncoderAttachment::try_new_with_frame_rate(width, height, frame_rate)
.map_err(|e| {
napi::Error::new(Status::GenericFailure, format!("attachEncoder failed: {e}"))
})?;
*self.inner.encoder_attachment.write() = Some(attachment);
emit_lifecycle(
&self.inner,
"diagnostic",
&format!(
"encoder ring attached: {width}x{height}@{}fps, capacity=8",
frame_rate.numerator
),
);
Ok(())
}
#[napi(js_name = "detachEncoder")]
pub fn detach_encoder(&self) -> Result<()> {
if let Some(attachment) = self.inner.encoder_attachment.write().take() {
attachment.detach();
}
emit_lifecycle(&self.inner, "diagnostic", "encoder ring detached");
Ok(())
}
#[napi(js_name = "isEncoderAttached")]
pub fn is_encoder_attached(&self) -> bool {
self.inner
.encoder_attachment
.read()
.as_ref()
.map(|attachment| attachment.is_attached())
.unwrap_or(false)
}
#[napi(js_name = "encoderRingFullCount")]
pub fn encoder_ring_full_count(&self) -> u32 {
self.inner
.encoder_attachment
.read()
.as_ref()
.map(|attachment| attachment.stats().ring_full_events.min(u32::MAX as u64) as u32)
.unwrap_or(0)
}
#[napi(js_name = "getEncoderAttachDiagnostics")]
pub fn get_encoder_attach_diagnostics(&self) -> Option<EncoderAttachDiagnostics> {
let guard = self.inner.encoder_attachment.read();
let attachment = guard.as_ref()?;
let stats = attachment.stats();
Some(EncoderAttachDiagnostics {
attached: attachment.is_attached(),
width: attachment.width(),
height: attachment.height(),
capacity: attachment.capacity().min(u32::MAX as usize) as u32,
frames_submitted: stats.frames_submitted as f64,
frames_dropped: stats.frames_dropped as f64,
ring_full_events: stats.ring_full_events as f64,
failed_blits: stats.failed_blits as f64,
})
}
}
fn record_start_options(
inner: &CaptureInner,
options: Option<ScreenCaptureStartOptions>,
) -> Result<CaptureStartOptionsDiagnostics> {
let state = build_start_option_diagnostics(options.unwrap_or_default())?;
if !state.unsupported_options.is_empty() {
emit_lifecycle(
inner,
"diagnostic",
&format!(
"Windows capture start options currently unsupported: {}",
state.unsupported_options.join(", ")
),
);
}
let mut guard = inner.start_options.lock();
*guard = state.clone();
Ok(state)
}
fn current_start_options(inner: &CaptureInner) -> CaptureStartOptionsDiagnostics {
inner.start_options.lock().clone()
}
fn build_start_option_diagnostics(
options: ScreenCaptureStartOptions,
) -> Result<CaptureStartOptionsDiagnostics> {
validate_capture_rect(options.capture_rect.as_ref())?;
validate_enum_option(
options.color_range.as_deref(),
"colorRange",
&["full", "limited"],
)?;
validate_enum_option(
options.color_space.as_deref(),
"colorSpace",
&["rec709", "srgb"],
)?;
let mut unsupported_options = Vec::with_capacity(START_OPTION_UNSUPPORTED_LIMIT);
if options.show_cursor_clicks.is_some() {
unsupported_options.push("showCursorClicks".to_string());
}
if options.capture_rect.is_some() {
unsupported_options.push("captureRect".to_string());
}
if options.color_range.is_some() {
unsupported_options.push("colorRange".to_string());
}
if options.color_space.is_some() {
unsupported_options.push("colorSpace".to_string());
}
assert!(
unsupported_options.len() <= START_OPTION_UNSUPPORTED_LIMIT,
"unsupported start-option list bounded"
);
Ok(CaptureStartOptionsDiagnostics {
show_cursor_clicks: options.show_cursor_clicks,
capture_rect: options.capture_rect,
color_range: options.color_range,
color_space: options.color_space,
unsupported_options,
})
}
fn validate_capture_rect(rect: Option<&ScreenCaptureRect>) -> Result<()> {
let Some(rect) = rect else {
return Ok(());
};
if !rect.x.is_finite() || !rect.y.is_finite() {
return Err(napi::Error::new(
Status::InvalidArg,
"captureRect x/y must be finite numbers",
));
}
if !rect.width.is_finite() || !rect.height.is_finite() {
return Err(napi::Error::new(
Status::InvalidArg,
"captureRect width/height must be finite numbers",
));
}
if rect.width <= 0.0 || rect.height <= 0.0 {
return Err(napi::Error::new(
Status::InvalidArg,
"captureRect requires positive width and height",
));
}
Ok(())
}
fn validate_enum_option(value: Option<&str>, name: &str, allowed: &[&str]) -> Result<()> {
let Some(value) = value else {
return Ok(());
};
if allowed.contains(&value) {
return Ok(());
}
Err(napi::Error::new(
Status::InvalidArg,
format!("invalid {name}: {value}"),
))
}
fn retain_native_frame_sink_handle(
value: Unknown<'_>,
) -> Result<Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>> {
if value.get_type()? != ValueType::External {
return Err(napi::Error::new(
Status::InvalidArg,
"ScreenCapture.setFrameSinkHandle expects a native external frame sink handle",
));
}
let raw_value = value.value();
let mut data: *mut c_void = std::ptr::null_mut();
let status =
unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) };
if status != napi::sys::Status::napi_ok || data.is_null() {
return Err(napi::Error::new(
Status::InvalidArg,
"ScreenCapture.setFrameSinkHandle received an empty native external frame sink handle",
));
}
let handle = unsafe {
data.cast::<fluxer_screen_frame_bus::NativeScreenFrameSinkHandle>()
.as_ref()
}
.and_then(fluxer_screen_frame_bus::NativeScreenFrameSinkHandle::retain_ref)
.ok_or_else(|| {
napi::Error::new(
Status::InvalidArg,
"ScreenCapture.setFrameSinkHandle received an invalid native frame sink handle",
)
})?;
Ok(Arc::new(handle))
}
impl Drop for ScreenCapture {
fn drop(&mut self) {
self.inner.native_frame_sink.lock().take();
}
}
#[cfg(target_os = "windows")]
impl ScreenCapture {
#[allow(clippy::too_many_arguments)]
fn start_windows(
&self,
source_id: String,
source_kind: String,
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<u32>,
_start_options: CaptureStartOptionsDiagnostics,
) -> Result<CaptureStartResult> {
use std::sync::atomic::Ordering;
if self.inner.running.load(Ordering::Acquire) {
return Err(napi::Error::from_reason("Capture already running"));
}
let target_frame_rate = frame_rate.unwrap_or(30).clamp(1, 144);
let frame_interval =
std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64);
if source_kind == "screen" {
let monitor = wgc_capture::parse_monitor_source_id(&source_id, &source_kind)
.ok_or_else(|| {
napi::Error::from_reason(format!("Invalid source: {source_kind}:{source_id}"))
})?;
if !wgc_capture::wgc_capture_supported() {
return Err(napi::Error::from_reason(
"Windows Graphics Capture is unavailable for screen capture",
));
}
let session = WgcCaptureSession::new_monitor(monitor, width, height).map_err(|e| {
napi::Error::from_reason(format!("Failed to create WGC screen capture: {e}"))
})?;
return self.start_windows_wgc_session(session, target_frame_rate);
}
let hwnd = if source_kind == "game" {
let target = capture_target::resolve_game_capture_target(&source_id, &source_kind)
.map_err(|e| {
napi::Error::from_reason(format!("Failed to resolve game capture target: {e}"))
})?;
windows::Win32::Foundation::HWND(target as *mut _)
} else {
dxgi_capture::parse_window_source_id(&source_id, &source_kind).ok_or_else(|| {
napi::Error::from_reason(format!("Invalid source: {source_kind}:{source_id}"))
})?
};
if let Some(result) = self.try_start_windows_wgc(hwnd, width, height, target_frame_rate)? {
return Ok(result);
}
let session = DxgiCaptureSession::new(hwnd, width, height)
.map_err(|e| napi::Error::from_reason(format!("Failed to create DXGI capture: {e}")))?;
let capture_width = session.capture_width();
let capture_height = session.capture_height();
{
let mut guard = self.inner.session.lock();
*guard = Some(session);
}
{
let mut guard = self.inner.fallback.lock();
*guard = Some(fallback::FallbackTracker::new(
fallback::CaptureStrategy::DxgiDuplication,
));
}
self.inner.running.store(true, Ordering::Release);
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("dxgi-capture".into())
.spawn(move || {
dxgi_capture::capture_loop(&inner, frame_interval);
})
.map_err(|e| {
napi::Error::from_reason(format!("Failed to spawn capture thread: {e}"))
})?;
Ok(CaptureStartResult {
width: capture_width,
height: capture_height,
frame_rate: target_frame_rate,
pixel_format: "bgra".to_string(),
})
}
fn try_start_windows_wgc(
&self,
hwnd: windows::Win32::Foundation::HWND,
width: Option<u32>,
height: Option<u32>,
target_frame_rate: u32,
) -> Result<Option<CaptureStartResult>> {
assert!(target_frame_rate >= 1, "frame rate at least 1");
assert!(target_frame_rate <= 144, "frame rate at most 144");
if !wgc_capture::wgc_capture_supported() {
return Ok(None);
}
let session = match WgcCaptureSession::new(hwnd, width, height) {
Ok(session) => session,
Err(e) => {
emit_lifecycle(
&self.inner,
"diagnostic",
&format!("WGC window capture unavailable; using DXGI duplication: {e}"),
);
return Ok(None);
}
};
self.start_windows_wgc_session(session, target_frame_rate)
.map(Some)
}
fn start_windows_wgc_session(
&self,
session: WgcCaptureSession,
target_frame_rate: u32,
) -> Result<CaptureStartResult> {
assert!(target_frame_rate >= 1, "frame rate at least 1");
assert!(target_frame_rate <= 144, "frame rate at most 144");
let capture_width = session.capture_width();
let capture_height = session.capture_height();
{
let mut guard = self.inner.wgc_session.lock();
*guard = Some(session);
}
{
let mut guard = self.inner.fallback.lock();
*guard = Some(fallback::FallbackTracker::new(
fallback::CaptureStrategy::Wgc,
));
}
self.inner.running.store(true, Ordering::Release);
let inner = Arc::clone(&self.inner);
let frame_interval =
std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64);
std::thread::Builder::new()
.name("wgc-capture".into())
.spawn(move || {
wgc_capture::capture_loop(&inner, frame_interval);
})
.map_err(|e| {
napi::Error::from_reason(format!("Failed to spawn WGC capture thread: {e}"))
})?;
Ok(CaptureStartResult {
width: capture_width,
height: capture_height,
frame_rate: target_frame_rate,
pixel_format: "bgra".to_string(),
})
}
}
fn strategy_only_diagnostics(
snapshot: &fallback::FallbackSnapshot,
start_options: CaptureStartOptionsDiagnostics,
frame_sink: FrameSinkCounterSnapshot,
) -> CaptureDiagnostics {
CaptureDiagnostics {
state: 0,
api_type: 0,
transport: 0,
fallback_reason: 0,
capture_flags: 0,
width: 0,
height: 0,
dxgi_format: 0,
frame_counter: 0.0,
dropped_frame_counter: 0.0,
last_present_timestamp_us: 0.0,
last_error: 0,
active_strategy: snapshot.active_strategy.clone(),
last_fallback_reason: snapshot.last_fallback_reason.clone(),
start_options,
frame_sink_accepted: frame_sink.accepted as f64,
frame_sink_coalesced: frame_sink.coalesced as f64,
frame_sink_rejected: frame_sink.rejected as f64,
media_frames_dropped_without_sink: frame_sink.dropped_without_sink as f64,
cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_options_are_kept_as_explicit_unsupported_state() {
let state = build_start_option_diagnostics(ScreenCaptureStartOptions {
show_cursor_clicks: Some(true),
capture_rect: Some(ScreenCaptureRect {
x: 10.0,
y: 20.0,
width: 300.0,
height: 200.0,
}),
color_range: Some("full".to_string()),
color_space: Some("rec709".to_string()),
})
.expect("valid options");
assert_eq!(state.show_cursor_clicks, Some(true));
assert_eq!(state.color_range.as_deref(), Some("full"));
assert_eq!(state.color_space.as_deref(), Some("rec709"));
assert_eq!(
state.unsupported_options,
vec![
"showCursorClicks".to_string(),
"captureRect".to_string(),
"colorRange".to_string(),
"colorSpace".to_string(),
]
);
}
#[test]
fn capture_rect_requires_positive_dimensions() {
let err = build_start_option_diagnostics(ScreenCaptureStartOptions {
capture_rect: Some(ScreenCaptureRect {
x: 0.0,
y: 0.0,
width: 0.0,
height: 10.0,
}),
..ScreenCaptureStartOptions::default()
})
.err();
assert!(err.is_some(), "invalid captureRect is rejected");
}
}
#[napi(js_name = "isSupported")]
pub fn is_supported() -> bool {
cfg!(target_os = "windows")
}
#[napi(js_name = "getAvailability")]
pub fn get_availability() -> AvailabilityInfo {
AvailabilityInfo {
available: cfg!(target_os = "windows"),
backend: "windows-game-capture".to_string(),
reason: if cfg!(target_os = "windows") {
None
} else {
Some("unsupported-platform".to_string())
},
}
}
#[napi(js_name = "listSources")]
pub fn list_sources() -> Result<Vec<ScreenCaptureSourceDescriptor>> {
Ok(sources::list_sources())
}
#[napi(js_name = "elevateGpuSchedulingPriority")]
pub fn elevate_gpu_scheduling_priority(
process_id: Option<u32>,
priority_class: Option<String>,
) -> Result<()> {
gpu_priority::elevate(process_id, priority_class).map_err(napi::Error::from_reason)
}
#[napi(js_name = "restoreGpuSchedulingPriority")]
pub fn restore_gpu_scheduling_priority(process_id: Option<u32>) -> Result<()> {
gpu_priority::restore(process_id).map_err(napi::Error::from_reason)
}