moq-video 0.0.23

Native video capture/encoding/decoding for Media over QUIC
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! Native Windows display capture via DXGI Desktop Duplication.
//!
//! Duplicates a monitor's output on a Direct3D11 device and pulls each desktop
//! frame off the GPU as a BGRA texture, copies it to a CPU staging texture, and
//! converts it to packed [`I420`] for the encoder. Whole-monitor capture only
//! (the Windows analogue of macOS ScreenCaptureKit display capture); per-window
//! capture would need Windows.Graphics.Capture instead.
//!
//! Runs on the shared blocking [`pump`] thread: `AcquireNextFrame` is a blocking
//! call with no async form, and the [`IDXGIOutputDuplication`] handle is `!Send`,
//! so building and driving it on one thread is the natural fit. The read loop
//! paces itself to the target frame rate, coalescing bursts of desktop updates
//! into one frame and re-emitting the last frame while the screen is static, so a
//! still desktop still produces a steady stream.

use std::time::{Duration, Instant};

use windows::Win32::Foundation::E_ACCESSDENIED;
use windows::Win32::Graphics::Direct3D11::{
	D3D11_CPU_ACCESS_READ, D3D11_MAP_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
	ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
};
use windows::Win32::Graphics::Dxgi::{
	DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_NOT_FOUND, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_FRAME_INFO, IDXGIAdapter,
	IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource,
};
use windows::core::Interface;

use super::channel::FrameChannel;
use super::pump::{self, Geometry};
use super::{Config, Stream};
use crate::Error;
use crate::frame::{I420, Surface, d3d11};

const DEFAULT_FRAMERATE: u32 = 30;

fn err(ctx: &str, e: windows::core::Error) -> Error {
	Error::Codec(anyhow::anyhow!("{ctx}: {e}"))
}

/// List the outputs on the same adapter [`open`] can capture.
pub(super) fn displays() -> Result<Vec<super::Display>, Error> {
	let device = d3d11::create_device()?;
	let adapter = adapter(&device)?;
	let mut displays = Vec::new();

	for index in 0.. {
		let output = match unsafe { adapter.EnumOutputs(index) } {
			Ok(output) => output,
			Err(error) if error.code() == DXGI_ERROR_NOT_FOUND => break,
			Err(error) => return Err(err("EnumOutputs", error)),
		};
		let desc = unsafe { output.GetDesc().map_err(|error| err("GetDesc", error))? };
		if !desc.AttachedToDesktop.as_bool() {
			continue;
		}

		let name_end = desc
			.DeviceName
			.iter()
			.position(|character| *character == 0)
			.unwrap_or(desc.DeviceName.len());
		let name = String::from_utf16_lossy(&desc.DeviceName[..name_end]);
		let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
		let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
		displays.push(super::Display {
			id: format!("display:{index}"),
			name,
			width,
			height,
		});
	}

	Ok(displays)
}

/// Open a display capture and stream its frames over a pump thread.
pub(super) async fn open(config: &Config, device: Option<&str>) -> Result<Stream, Error> {
	let config = config.clone();
	// The device opens on the pump thread, so the selector has to be owned.
	let device = device.map(str::to_string);
	let chan = FrameChannel::new();
	let (geo, guard) = pump::spawn(
		chan.clone(),
		move || {
			let cap = Duplicator::open(&config, device.as_deref())?;
			let geometry = Geometry {
				width: cap.width,
				height: cap.height,
				framerate: Some(cap.framerate),
				label: cap.device_name.clone(),
			};
			Ok((cap, geometry))
		},
		Duplicator::read,
	)
	.await?;

	Ok(Stream::new(
		chan,
		geo.width,
		geo.height,
		geo.framerate,
		geo.label,
		None,
		Box::new(guard),
	))
}

/// An open monitor duplication, read frame-by-frame on the pump thread.
struct Duplicator {
	device: ID3D11Device,
	context: ID3D11DeviceContext,
	/// The duplicated output, kept so the duplication can be rebuilt after a
	/// `DXGI_ERROR_ACCESS_LOST` (a mode switch or fullscreen transition).
	output: IDXGIOutput1,
	dupl: IDXGIOutputDuplication,
	/// Reused CPU-readable copy of the desktop texture (constant size).
	staging: Option<ID3D11Texture2D>,
	/// Even-clamped capture size (I420 chroma is 2x2, so dimensions must be even).
	width: u32,
	height: u32,
	framerate: u32,
	interval: Duration,
	/// When the next frame is due; paces `read` to `framerate`.
	next_deadline: Option<Instant>,
	/// Most recent decoded frame, re-emitted while the screen is static.
	last: Option<I420>,
	device_name: String,
}

impl Duplicator {
	fn open(config: &Config, selector: Option<&str>) -> Result<Self, Error> {
		let device = d3d11::create_device()?;
		let context = unsafe {
			device
				.GetImmediateContext()
				.map_err(|e| err("GetImmediateContext", e))?
		};

		let index = select_output(selector)?;
		let output = enumerate_output(&device, index)?;
		let device_name = format!("display:{index}");
		let dupl = duplicate(&output, &device)?;

		let desc = unsafe { dupl.GetDesc() };
		// I420 needs even dimensions; clamp down (drop the last odd row/column).
		let width = desc.ModeDesc.Width & !1;
		let height = desc.ModeDesc.Height & !1;
		if width == 0 || height == 0 {
			return Err(Error::Codec(anyhow::anyhow!(
				"display {index} reported an unusable size {}x{}",
				desc.ModeDesc.Width,
				desc.ModeDesc.Height
			)));
		}

		let framerate = config.framerate.unwrap_or(DEFAULT_FRAMERATE).max(1);
		let mut cap = Self {
			device,
			context,
			output,
			dupl,
			staging: None,
			width,
			height,
			framerate,
			interval: Duration::from_micros(1_000_000 / framerate as u64),
			next_deadline: None,
			last: None,
			device_name,
		};

		// Seed the first frame so a static screen still has something to emit, and
		// so a broken duplication path fails here at open rather than mid-stream.
		// The desktop image may not be ready instantly, so retry briefly.
		for _ in 0..20 {
			if cap.capture_once(100)? {
				break;
			}
		}
		if cap.last.is_none() {
			return Err(Error::Codec(anyhow::anyhow!("no desktop frame within timeout")));
		}

		tracing::info!(
			display = %cap.device_name,
			width = cap.width,
			height = cap.height,
			framerate = cap.framerate,
			"opened Desktop Duplication capture"
		);
		Ok(cap)
	}

	/// Rebuild the duplication after `DXGI_ERROR_ACCESS_LOST` (e.g. a resolution
	/// change or a fullscreen exclusive app grabbing/releasing the output).
	fn reduplicate(&mut self) -> Result<(), Error> {
		self.dupl = duplicate(&self.output, &self.device).map_err(|error| match error {
			Error::PermissionDenied(_) => error,
			error => Error::SourceUnavailable(format!("{}: {error}", self.device_name)),
		})?;
		self.staging = None;
		Ok(())
	}

	/// Acquire at most one desktop frame, waiting up to `timeout_ms`. Returns
	/// `true` if a frame was captured into `last`, `false` on timeout (no update).
	fn capture_once(&mut self, timeout_ms: u32) -> Result<bool, Error> {
		let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
		let mut resource: Option<IDXGIResource> = None;
		match unsafe { self.dupl.AcquireNextFrame(timeout_ms, &mut info, &mut resource) } {
			Ok(()) => {}
			Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => return Ok(false),
			Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => {
				self.reduplicate()?;
				return Ok(false);
			}
			Err(e) => {
				return Err(Error::SourceUnavailable(format!(
					"{}: AcquireNextFrame: {e}",
					self.device_name
				)));
			}
		}

		let resource = resource.ok_or_else(|| Error::Codec(anyhow::anyhow!("AcquireNextFrame returned no surface")))?;
		let texture = resource
			.cast::<ID3D11Texture2D>()
			.map_err(|e| err("desktop surface is not a texture", e))?;

		// Always release the frame, even if the copy fails, or the next acquire
		// deadlocks (only one frame may be held at a time).
		let result = self.copy_to_last(&texture);
		unsafe {
			let _ = self.dupl.ReleaseFrame();
		}
		result?;
		Ok(true)
	}

	/// Copy the desktop texture to the staging texture and convert BGRA -> I420.
	fn copy_to_last(&mut self, texture: &ID3D11Texture2D) -> Result<(), Error> {
		let staging = self.ensure_staging(texture)?;
		unsafe { self.context.CopyResource(&staging, texture) };

		let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
		unsafe {
			self.context
				.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
				.map_err(|e| err("Map (staging)", e))?;
		}
		let _guard = UnmapGuard {
			context: &self.context,
			resource: &staging,
		};

		let pitch = mapped.RowPitch;
		let len = pitch as usize * self.height as usize;
		let bgra = unsafe { std::slice::from_raw_parts(mapped.pData as *const u8, len) };
		self.last = Some(I420::from_bgra(bgra, pitch, self.width, self.height)?);
		Ok(())
	}

	/// Lazily create (and cache) a CPU-readable staging texture matching the
	/// desktop texture's format and size.
	fn ensure_staging(&mut self, texture: &ID3D11Texture2D) -> Result<ID3D11Texture2D, Error> {
		if let Some(staging) = &self.staging {
			return Ok(staging.clone());
		}
		let mut desc = D3D11_TEXTURE2D_DESC::default();
		unsafe { texture.GetDesc(&mut desc) };
		desc.Usage = D3D11_USAGE_STAGING;
		desc.BindFlags = 0;
		desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32;
		desc.MiscFlags = 0;
		desc.ArraySize = 1;
		desc.MipLevels = 1;

		let mut staging: Option<ID3D11Texture2D> = None;
		unsafe {
			self.device
				.CreateTexture2D(&desc, None, Some(&mut staging))
				.map_err(|e| err("CreateTexture2D (staging)", e))?;
		}
		let staging = staging.ok_or_else(|| Error::Codec(anyhow::anyhow!("CreateTexture2D returned null")))?;
		self.staging = Some(staging.clone());
		Ok(staging)
	}

	/// Capture the next frame, paced to the target frame rate. Coalesces a burst
	/// of desktop updates into the latest frame, and re-emits the last frame when
	/// the screen hasn't changed, so the output rate stays steady.
	fn read(&mut self) -> Result<pump::Read, Error> {
		let deadline = *self.next_deadline.get_or_insert_with(|| Instant::now() + self.interval);

		loop {
			let now = Instant::now();
			if now >= deadline {
				break;
			}
			let remaining = (deadline - now).as_millis().max(1) as u32;
			// Blocks up to `remaining`; a new frame returns early, a static screen
			// times out at the deadline. Either way we exit the loop at the deadline.
			self.capture_once(remaining)?;
		}

		// Schedule the next frame; if we fell badly behind (a long stall), reset to
		// now so we don't then burst to catch up.
		let next = deadline + self.interval;
		self.next_deadline = Some(next.max(Instant::now()));

		// A screen that hasn't changed since the stream opened has no frame to hand
		// back yet, which is not the duplication API going away.
		Ok(self
			.last
			.clone()
			.map_or(pump::Read::Idle, |frame| pump::Read::Frame(Surface::I420(frame))))
	}
}

struct UnmapGuard<'a> {
	context: &'a ID3D11DeviceContext,
	resource: &'a ID3D11Texture2D,
}

impl Drop for UnmapGuard<'_> {
	fn drop(&mut self) {
		unsafe { self.context.Unmap(self.resource, 0) };
	}
}

/// Which monitor to capture: a bare index or the `display:{index}` form that
/// [`Stream::device`](super::Stream) reports; `None` is the first one.
fn select_output(selector: Option<&str>) -> Result<u32, Error> {
	match selector {
		None => Ok(0),
		Some(spec) => spec
			.strip_prefix("display:")
			.unwrap_or(spec)
			.parse::<u32>()
			.map_err(|_| Error::Codec(anyhow::anyhow!("invalid display selector {spec:?}"))),
	}
}

/// Get the `index`th output (monitor) attached to the device's adapter.
fn enumerate_output(device: &ID3D11Device, index: u32) -> Result<IDXGIOutput1, Error> {
	let adapter = adapter(device)?;
	let output = unsafe {
		adapter
			.EnumOutputs(index)
			.map_err(|_| Error::SourceUnavailable(format!("no display at index {index}")))?
	};
	output
		.cast::<IDXGIOutput1>()
		.map_err(|e| err("output is not IDXGIOutput1", e))
}

/// The DXGI adapter backing a Direct3D device.
fn adapter(device: &ID3D11Device) -> Result<IDXGIAdapter, Error> {
	let dxgi = device
		.cast::<IDXGIDevice>()
		.map_err(|e| err("device is not a DXGI device", e))?;
	unsafe { dxgi.GetAdapter().map_err(|e| err("GetAdapter", e)) }
}

/// Start duplicating `output` on `device`.
fn duplicate(output: &IDXGIOutput1, device: &ID3D11Device) -> Result<IDXGIOutputDuplication, Error> {
	unsafe { output.DuplicateOutput(device) }.map_err(|error| {
		if error.code() == E_ACCESSDENIED {
			Error::PermissionDenied(format!("display capture: {error}"))
		} else {
			err("DuplicateOutput", error)
		}
	})
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::capture::Config;
	use crate::frame::Surface;

	/// Open the primary display, grab a few frames, and check geometry + frame
	/// size. Ignored because Desktop Duplication needs an interactive desktop
	/// session with a GPU output; skips cleanly when that isn't available.
	#[test]
	#[ignore]
	fn duplicates_primary_display() {
		let mut cap = match Duplicator::open(&Config::default(), None) {
			Ok(cap) => cap,
			Err(e) => {
				eprintln!("skipping: no Desktop Duplication available: {e}");
				return;
			}
		};

		assert!(cap.width >= 2 && cap.width % 2 == 0, "bad width {}", cap.width);
		assert!(cap.height >= 2 && cap.height % 2 == 0, "bad height {}", cap.height);

		for i in 0..5 {
			let frame = cap.read().expect("read frame");
			let pump::Read::Frame(Surface::I420(i420)) = frame else {
				panic!("frame {i} was not I420");
			};
			assert_eq!(i420.width, cap.width);
			assert_eq!(i420.height, cap.height);
			assert_eq!(i420.data.len(), I420::len(cap.width, cap.height));
		}
		eprintln!("captured 5 frames at {}x{}", cap.width, cap.height);
	}
}