feat(capture): record the screen

Screen capture behind a `CaptureBackend`: ScreenCaptureKit and AVFoundation on
macOS (HEVC by default), Windows Graphics Capture and a Media Foundation writer
on Windows (H.264). Each display is written as fragmented mp4 in segments with
every frame's master-clock timestamp, and a manager decides, from what the
backend reports, when a segment ends and another begins. The segment logic is
shared by both backends and tested without a screen.
This commit is contained in:
LGUG2Z committed 2026-10-27 22:22:56 -07:00
1 parent 9d35869f88
commit fb11cb2db9
19 files changed
+6166 -1

No files matched your search

Generated
+149
View File
@@ -109,6 +109,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -260,6 +269,18 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -461,14 +482,21 @@ dependencies = [
name = "komorebi-recorder"
version = "0.1.0"
dependencies = [
"block2",
"chrono",
"clap",
"color-eyre",
"crossbeam-channel",
"dirs",
"dispatch2",
"libc",
"objc2",
"objc2-av-foundation",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-media",
"objc2-foundation",
"objc2-screen-capture-kit",
"parking_lot",
"schemars",
"serde",
@@ -558,6 +586,51 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-av-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0"
dependencies = [
"bitflags",
"block2",
"objc2",
"objc2-core-foundation",
"objc2-core-media",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -565,6 +638,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
]
[[package]]
@@ -574,8 +649,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-media"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-core-video",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-screen-capture-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394"
dependencies = [
"block2",
"dispatch2",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-media",
"objc2-foundation",
]
[[package]]
+20 -1
View File
@@ -33,6 +33,7 @@
"crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index",
"dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index",
"dirs-sys 0.5.0 registry+https://github.com/rust-lang/crates.io-index",
"dispatch2 0.3.1 registry+https://github.com/rust-lang/crates.io-index",
"dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index",
"errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index",
"eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index",
@@ -49,8 +50,12 @@
"lock_api 0.4.14 registry+https://github.com/rust-lang/crates.io-index",
"miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index",
"num-traits 0.2.19 registry+https://github.com/rust-lang/crates.io-index",
"objc2-av-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-media 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-video 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-screen-capture-kit 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"object 0.37.3 registry+https://github.com/rust-lang/crates.io-index",
"once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index",
"once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index",
@@ -129,6 +134,7 @@
"autocfg 1.5.0 registry+https://github.com/rust-lang/crates.io-index",
"backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index",
"bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index",
"block2 0.6.2 registry+https://github.com/rust-lang/crates.io-index",
"cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index",
"chrono 0.4.44 registry+https://github.com/rust-lang/crates.io-index",
"clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index",
@@ -143,6 +149,7 @@
"crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index",
"dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index",
"dirs-sys 0.5.0 registry+https://github.com/rust-lang/crates.io-index",
"dispatch2 0.3.1 registry+https://github.com/rust-lang/crates.io-index",
"dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index",
"errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index",
"eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index",
@@ -161,8 +168,15 @@
"memoffset 0.9.1 registry+https://github.com/rust-lang/crates.io-index",
"miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index",
"num-traits 0.2.19 registry+https://github.com/rust-lang/crates.io-index",
"objc2 0.6.4 registry+https://github.com/rust-lang/crates.io-index",
"objc2-av-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-media 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-video 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-encode 4.1.0 registry+https://github.com/rust-lang/crates.io-index",
"objc2-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-screen-capture-kit 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"object 0.37.3 registry+https://github.com/rust-lang/crates.io-index",
"once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index",
"once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index",
@@ -241,9 +255,14 @@
[
"Zlib",
[
"dispatch2 0.3.1 registry+https://github.com/rust-lang/crates.io-index",
"miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index",
"objc2-av-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index"
"objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-media 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-core-video 0.3.2 registry+https://github.com/rust-lang/crates.io-index",
"objc2-screen-capture-kit 0.3.2 registry+https://github.com/rust-lang/crates.io-index"
]
]
]
+8
View File
@@ -75,6 +75,14 @@ The files beside it are the raw records, and they are written as the data arrive
(`komorebi.log`, the exact byte range recorded), its events (`events.jsonl`, with full `State` snapshots in `states/`
only when they changed), and what happened to the recording itself (`recorder.jsonl`).
The OS trace is under `oslog/`, one file per stream.
Each display's video is `video-<id>[-<n>].mp4` with a `frames-<id>[-<n>].csv` beside it holding every frame's timestamp,
one segment to a file: a display that is unplugged and returns starts a new segment instead of extending the old one.
## Recording
The recorder starts its sources in an order that makes the sources that are not video a superset of the video's span
(the clock anchor, the OS trace streams, the subscription to komorebi and its first `State`, komorebi's log offset, and
last the video), and stops them in the reverse order.
## Rules and the layer that enforces them
+15
View File
@@ -46,6 +46,21 @@ Video frame times and the OS trace are on the master clock already on both hosts
`machTimestamp` on a Mac; Windows.Graphics.Capture on Windows). The field names ending in `_mach_ns` are spelled that way
on every host: see [the architecture](ARCHITECTURE.md#the-master-clock).
### Capturing the screen
| | macOS | Windows |
|---|---|---|
| API | ScreenCaptureKit, through `objc2` bindings, and AVFoundation's `AVAssetWriter` | Windows.Graphics.Capture (Windows 10 version 1803 or later), and a Media Foundation MP4 sink |
| permission | Screen Recording, granted to the terminal or app that launches the binary | none |
| default codec | HEVC | H.264 |
| other codec | H.264 (`--codec h264`) | HEVC is refused with an error: Media Foundation's MP4 sink does not write it |
| what is captured | each display, excluding the recorder's own processes by name | each monitor whole, so the program's windows ask to be left out (see below) |
| a still screen | a healthy stream still calls back about 30 times a second, so silence for four seconds means a stalled stream | Windows sends nothing while the picture does not change, so silence says nothing there |
The video of a display is written as fragmented MP4 on both hosts, in segments, so that the part written before a crash
can still be played. Because ScreenCaptureKit only emits frames when the screen changes, the macOS capture re-submits the
last frame so that a static display still flushes fragments.
### The OS trace
| | macOS | Windows |
+48
View File
@@ -17,7 +17,24 @@ serde = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6"
dispatch2 = "0.3"
libc = "0.2"
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-av-foundation = { version = "0.3", default-features = false, features = [
"std",
"block2",
"objc2-core-media",
"AVAsset",
"AVAssetReader",
"AVAssetReaderOutput",
"AVAssetTrack",
"AVAssetWriter",
"AVAssetWriterInput",
"objc2-core-foundation",
"AVMediaFormat",
"AVVideoSettings",
] }
objc2-core-foundation = { version = "0.3", default-features = false, features = [
"std",
"CFArray",
@@ -37,6 +54,37 @@ objc2-core-graphics = { version = "0.3", default-features = false, features = [
"CGSession",
"CGWindow",
] }
objc2-core-media = { version = "0.3", default-features = false, features = [
"std",
"objc2-core-video",
"CMBase",
"CMSampleBuffer",
"CMSync",
"CMTime",
"CMTimeRange",
] }
objc2-foundation = { version = "0.3", default-features = false, features = [
"std",
"NSArray",
"NSDictionary",
"NSEnumerator",
"NSError",
"NSObject",
"NSString",
"NSURL",
"NSValue",
] }
objc2-screen-capture-kit = { version = "0.3", default-features = false, features = [
"std",
"block2",
"dispatch2",
"libc",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-media",
"SCShareableContent",
"SCStream",
] }
[target.'cfg(windows)'.dependencies]
uds_windows = "1"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+571
View File
@@ -0,0 +1,571 @@
//! The parts of recording one segment that do not depend on which OS captured it.
//!
//! They are where each frame is placed in the file's time, the log of when each arrived, when an
//! idle display needs a frame repeated to keep its file moving, and how to size the encoding.
//!
//! A backend feeds frames in with their timestamps on the master clock and gets back where to put
//! them, so the rules that make a segment's file and its frame log agree are in one place and are
//! tested without a screen.
//!
//! | item | what it decides |
//! |---|---|
//! | [`Placement`] | where each frame, and each repeat of the last frame, goes in the file's time, and that the encoder never sees a time that is not later than the last |
//! | [`FrameLog`] | the `frames-<display>[-<n>].csv` file: when each frame was shown and received, and a row for each repeat |
//! | [`keepalive_due`], [`KEEPALIVE_AFTER`], [`KEEPALIVE_SOURCE_TIMEOUT_NS`] | when a display that has shown nothing new needs its last frame repeated |
//! | [`Latency`] | the clock-unification check, as [`LatencyStats`] |
//! | [`even`], [`h264_bitrate`], [`rebased_100ns`] | the encoding's size and bitrate, and the file's unit of time |
//!
//! # Who uses it
//!
//! The Windows backend (`wgc`) uses all of it. The macOS backend (`sck`) uses only [`even`] and
//! [`Latency`]: it applies the same placement, frame-log and keep-alive rules inline, with the same
//! numbers and private copies of the constants. The unit tests here therefore cover what
//! the Windows backend relies on, and the macOS equivalents are not covered by them.
//!
//! # Time
//!
//! Every `*_ns` argument and result is a time on the master clock (see [`crate::clock`]):
//! nanoseconds on the host's monotonic clock. A time in the file itself, as in
//! [`Placed::file_time`], is in the 100 ns units Media Foundation counts in, measured from the
//! segment's first frame.
use crate::capture::LatencyStats;
use std::fs::File;
use std::io::BufWriter;
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
/// How long a display must have shown nothing new before its last frame is repeated: 500 ms.
///
/// The repeat means a crash would lose only what is in the encoder's buffers: a screen that never
/// changed after the first second would otherwise leave a file that never got past its first
/// fragment. Repeating a frame costs a few bytes (it is identical). See [`keepalive_due`].
pub const KEEPALIVE_AFTER: Duration = Duration::from_millis(500);
/// How long a source may be silent and still be kept alive, in nanoseconds: 2 seconds.
///
/// A source silent for longer than this is not kept alive: repeating the last frame of a capture
/// that has silently died would make a frozen video look healthy while the screen moves on. See
/// [`keepalive_due`].
pub const KEEPALIVE_SOURCE_TIMEOUT_NS: u64 = 2_000_000_000;
/// How often the frame log is flushed while frames arrive: one second.
///
/// This bounds what a crash can lose from the log. See [`FrameLog`] for what is not covered.
pub const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
/// How much later than the last thing written the next must be, at the least.
const MIN_STEP_NS: u64 = 1_000_000;
/// Returns `value` rounded to a whole number and then down to an even number, and never less than
/// two: video codecs want even sizes.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::even;
///
/// assert_eq!(even(1920.0), 1920);
/// assert_eq!(even(1919.0), 1918);
/// assert_eq!(even(1920.6), 1920);
/// assert_eq!(even(1921.6), 1922);
/// // Never below two, whatever it is given.
/// assert_eq!(even(1.0), 2);
/// assert_eq!(even(0.0), 2);
/// assert_eq!(even(-640.0), 2);
/// ```
#[must_use]
pub fn even(value: f64) -> usize {
let value = value.round().max(2.0) as usize;
value & !1
}
/// Returns the bitrate to encode a screen of this size at, in bits per second.
///
/// Screen content is mostly flat and changes in bursts, so this is about a tenth of a bit per pixel
/// per frame at the frame rate cap, which is generous for text and keeps a 4K display under a
/// hardware encoder's usual limit. The result is clamped to between 1.5 and 40 megabits per
/// second, and an `fps` of `0` counts as `1`.
///
/// Only the Windows backend calls it: the macOS backend does not set a bitrate.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::h264_bitrate;
///
/// // A tenth of a bit per pixel per frame: 1920 * 1080 * 30 * 0.1.
/// assert_eq!(h264_bitrate(1920, 1080, 30), 6_220_800);
/// assert!(h264_bitrate(3840, 2160, 30) > h264_bitrate(1920, 1080, 30));
///
/// // A floor for a tiny screen and a ceiling for a huge one.
/// assert_eq!(h264_bitrate(100, 100, 1), 1_500_000);
/// assert_eq!(h264_bitrate(7680, 4320, 60), 40_000_000);
/// ```
#[must_use]
pub fn h264_bitrate(width: usize, height: usize, fps: u32) -> u32 {
let wanted = width as f64 * height as f64 * f64::from(fps.max(1)) * 0.1;
wanted.clamp(1_500_000.0, 40_000_000.0) as u32
}
/// Returns how long after `first_pts_ns` the time `pts_ns` is, in the 100 ns units Media Foundation
/// counts in.
///
/// This is the time a frame has in the segment's file, whose time zero is its first frame. A
/// `pts_ns` that is not later than `first_pts_ns` gives `0`; a fraction of a unit is dropped.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::rebased_100ns;
///
/// // 33 ms after the first frame is 330 000 units of 100 ns.
/// assert_eq!(rebased_100ns(5_033_000_000, 5_000_000_000), 330_000);
/// assert_eq!(rebased_100ns(5_000_000_000, 5_000_000_000), 0);
/// assert_eq!(rebased_100ns(4_000_000_000, 5_000_000_000), 0);
/// ```
#[must_use]
pub fn rebased_100ns(pts_ns: u64, first_pts_ns: u64) -> i64 {
i64::try_from(pts_ns.saturating_sub(first_pts_ns) / 100).unwrap_or(i64::MAX)
}
/// Where each thing appended to a segment goes in time.
///
/// The encoder rejects a frame that is not later than the one before it. Frames carry the time the
/// display produced them, which runs slightly ahead of when they are received, so a keep-alive
/// stamped "now" can land a hair after the next real frame; the real frame is moved just past it
/// rather than losing it or the segment.
///
/// It is guaranteed that every [`Placed::pts_ns`] it returns is later than the one before. A
/// frame that is not later than the last thing placed is moved to 1 ms after it, and a repeat is
/// never less than 1 ms after it. The first frame defines the start of the segment and is never
/// moved. A `Placement` is per segment: start a new one for the next.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::Placement;
///
/// let mut placement = Placement::default();
/// assert_eq!(placement.first_ns(), None);
///
/// // The first frame is time zero of the file; 33 ms later is 330 000 units of 100 ns.
/// let first = placement.frame(5_000_000_000);
/// assert_eq!(first.file_time, 0);
/// let next = placement.frame(5_033_000_000);
/// assert_eq!(next.file_time, 330_000);
/// assert!(!next.moved);
///
/// // A repeat stamped "now" ran a hair ahead of the next real frame, which is moved just past it
/// // instead of being rejected.
/// placement.repeat(5_600_000_000);
/// let late = placement.frame(5_599_000_000);
/// assert!(late.moved);
/// assert_eq!(late.pts_ns, 5_601_000_000);
/// assert_eq!(placement.last_ns(), 5_601_000_000);
/// ```
#[derive(Debug, Default)]
pub struct Placement {
first_ns: Option<u64>,
last_ns: u64,
}
/// Where something was placed: what [`Placement::frame`] and [`Placement::repeat`] return.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placed {
/// On the master clock, after any moving to keep it in order. This is the time to log.
pub pts_ns: u64,
/// Within the segment's file, in 100 ns units: zero for the first. This is the time to hand to
/// the encoder.
pub file_time: i64,
/// Whether it was moved from the time it was given.
pub moved: bool,
}
impl Placement {
/// The master-clock time of the segment's first frame, once it has one.
#[must_use]
pub fn first_ns(&self) -> Option<u64> {
self.first_ns
}
/// The master-clock time of the last thing appended (`0` before any).
#[must_use]
pub fn last_ns(&self) -> u64 {
self.last_ns
}
/// Places a real frame produced at `pts_ns`. The first one defines the start of the segment.
///
/// A frame that is not later than the last thing placed is moved to 1 ms after it
/// ([`Placed::moved`] is then `true`).
pub fn frame(&mut self, pts_ns: u64) -> Placed {
let first = *self.first_ns.get_or_insert(pts_ns);
let adjusted = if self.last_ns > 0 && pts_ns <= self.last_ns {
self.last_ns + MIN_STEP_NS
} else {
pts_ns
};
self.last_ns = adjusted;
Placed {
pts_ns: adjusted,
file_time: rebased_100ns(adjusted, first),
moved: adjusted != pts_ns,
}
}
/// Places a repeat of the last frame at `now_ns`. `None` before there is a first frame to
/// repeat.
///
/// A repeat is never earlier than 1 ms after the last thing placed, so one asked for "now"
/// right after a frame that ran ahead of the clock is moved later ([`Placed::moved`]).
pub fn repeat(&mut self, now_ns: u64) -> Option<Placed> {
let first = self.first_ns?;
let adjusted = now_ns.max(self.last_ns + MIN_STEP_NS);
self.last_ns = adjusted;
Some(Placed {
pts_ns: adjusted,
file_time: rebased_100ns(adjusted, first),
moved: adjusted != now_ns,
})
}
}
/// Returns `true` if and only if an idle segment is due a repeated frame.
///
/// Only while the source is demonstrably alive (`source_signal_ns` is when it last did anything,
/// `None` if the backend cannot tell, which counts as alive) and the segment has been still for
/// [`KEEPALIVE_AFTER`]. A source silent for [`KEEPALIVE_SOURCE_TIMEOUT_NS`] or longer is not alive.
/// `last_append_ns` is the master-clock time of the last thing written to the segment, as
/// [`Placement::last_ns`] reports it.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::keepalive_due;
///
/// let half_second = 500_000_000;
/// // Still for half a second, and the source is alive or cannot say.
/// assert!(keepalive_due(half_second, 0, None));
/// assert!(keepalive_due(half_second, 0, Some(half_second - 1)));
/// // Not still for long enough.
/// assert!(!keepalive_due(half_second - 1, 0, None));
/// // The source has been silent for 5 seconds: a dead capture is not made to look alive.
/// assert!(!keepalive_due(5_000_000_000, 0, Some(0)));
/// ```
#[must_use]
pub fn keepalive_due(now_ns: u64, last_append_ns: u64, source_signal_ns: Option<u64>) -> bool {
let alive = source_signal_ns
.is_none_or(|last| now_ns.saturating_sub(last) < KEEPALIVE_SOURCE_TIMEOUT_NS);
let still_for = now_ns.saturating_sub(last_append_ns);
alive && still_for >= u64::try_from(KEEPALIVE_AFTER.as_nanos()).unwrap_or(u64::MAX)
}
/// Accumulates how far the frames' times lag behind the moment they were received.
///
/// If the frames were not on the host clock this would be wildly off, which makes it the
/// clock-unification check. [`Latency::finish`] reads out a [`LatencyStats`] without ending the
/// accumulation, so a segment can be summarized while it is still recording.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::Latency;
///
/// let mut latency = Latency::default();
/// // No frames, nothing to doubt.
/// assert!(latency.finish().looks_like_host_clock());
///
/// // Received minus stamped, in nanoseconds: the first frame was stamped 5 ms ahead.
/// for ns in [-5_000_000, 10_000_000, 30_000_000] {
/// latency.record(ns);
/// }
/// let stats = latency.finish();
/// assert_eq!((stats.samples, stats.min_ns, stats.max_ns), (3, -5_000_000, 30_000_000));
/// assert_eq!(stats.mean_ns, 11_666_666);
/// assert!(stats.looks_like_host_clock());
/// ```
#[derive(Default)]
pub struct Latency {
samples: u64,
min_ns: i64,
max_ns: i64,
sum_ns: i128,
}
impl Latency {
/// Adds one frame's latency: the time it was received minus the time it is stamped with, in
/// nanoseconds. Negative when the stamp is ahead of the arrival.
pub fn record(&mut self, latency_ns: i64) {
if self.samples == 0 {
self.min_ns = latency_ns;
self.max_ns = latency_ns;
} else {
self.min_ns = self.min_ns.min(latency_ns);
self.max_ns = self.max_ns.max(latency_ns);
}
self.samples += 1;
self.sum_ns += i128::from(latency_ns);
}
/// Returns the statistics of the latencies recorded so far. With none, all zeros.
#[must_use]
pub fn finish(&self) -> LatencyStats {
LatencyStats {
samples: self.samples,
min_ns: self.min_ns,
max_ns: self.max_ns,
mean_ns: if self.samples == 0 {
0
} else {
(self.sum_ns / i128::from(self.samples)) as i64
},
}
}
}
/// `frames-<display>[-<n>].csv`: every frame's time on the master clock and when it was received,
/// and a `-` row for each repeat.
///
/// It is authoritative for when each frame was shown, whatever the file's own times say.
///
/// There is one row per line, `<index>,<pts_ns>,<received_ns>`, with both times in nanoseconds on
/// the master clock. A repeat of the last frame, which keeps the file moving while the display is
/// still, has `-` in place of an index and is not counted as a frame:
///
/// ```text
/// 0,5000000000,5000900000
/// 1,5033000000,5034000000
/// -,5600000000,5600000000
/// ```
///
/// [`FrameLog::create`] writes no header row. The reader of the file (`recovery`) skips the first
/// line of the file as a header; the macOS backend writes `index,pts_mach_ns,received_mach_ns`
/// there itself, and the Windows backend does not.
///
/// # Durability
///
/// Rows are buffered. [`FrameLog::frame`] flushes when [`LOG_FLUSH_INTERVAL`] has passed since the
/// last flush, and [`FrameLog::flush`] flushes at once. [`FrameLog::repeat`] does not flush, so
/// while a display is still the rows for its repeats stay in memory until the next real frame or
/// [`FrameLog::flush`]. A crash can lose what is still buffered, and can leave the last row of the
/// file cut short if the buffer filled in the middle of it, so a reader has to ignore a row that
/// does not parse (`recovery` does). A write that fails is ignored: the video is the source of
/// truth.
///
/// # Example
///
/// ```rust
/// use komorebi_recorder::capture::segment::FrameLog;
///
/// let path = std::env::temp_dir()
/// .join(format!("komorebi-recorder-doc-frame-log-{}.csv", std::process::id()));
/// let mut log = FrameLog::create(&path)?;
/// log.frame(100, 105); // stamped 100, received at 105
/// log.repeat(700, 710); // the display was still: the last frame repeated
/// log.frame(900, 903);
/// log.flush();
///
/// assert_eq!(log.frames(), 2, "a repeat is not a frame");
/// assert_eq!(std::fs::read_to_string(&path)?, "0,100,105\n-,700,710\n1,900,903\n");
/// # std::fs::remove_file(&path)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub struct FrameLog {
out: BufWriter<File>,
last_flush: Instant,
index: u64,
}
impl FrameLog {
/// Creates the log at `path`, truncating a file that is already there. Nothing is written
/// until the first row.
///
/// # Errors
///
/// Returns the I/O error if the file cannot be created: the directory does not exist, or the
/// process may not write there.
pub fn create(path: &Path) -> std::io::Result<Self> {
Ok(Self {
out: BufWriter::new(File::create(path)?),
last_flush: Instant::now(),
index: 0,
})
}
/// Returns how many real frames have been logged. Repeats are not counted.
#[must_use]
pub fn frames(&self) -> u64 {
self.index
}
/// Logs a real frame stamped `pts_ns` and received at `received_ns`, both on the master clock.
///
/// A failed write must not take down the capture: the video is the source of truth and this is
/// how it is read. So a failed write is not reported and the row is lost, and the frame is
/// still counted by [`FrameLog::frames`].
pub fn frame(&mut self, pts_ns: u64, received_ns: u64) {
let _ = writeln!(self.out, "{},{pts_ns},{received_ns}", self.index);
self.index += 1;
self.flush_if_due();
}
/// Logs a repeat of the last frame at `pts_ns`, made at `now_ns`: not a new frame, and marked
/// so, but it keeps the log's idea of where the segment ends honest.
pub fn repeat(&mut self, pts_ns: u64, now_ns: u64) {
let _ = writeln!(self.out, "-,{pts_ns},{now_ns}");
}
fn flush_if_due(&mut self) {
if self.last_flush.elapsed() >= LOG_FLUSH_INTERVAL {
// on whole lines, so a crash never leaves a torn row behind
let _ = self.out.flush();
self.last_flush = Instant::now();
}
}
/// Writes what is buffered to the file. A failed write is ignored, as in [`FrameLog::frame`].
pub fn flush(&mut self) {
let _ = self.out.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sizes_are_even_and_never_below_two() {
assert_eq!(even(1920.0), 1920);
assert_eq!(even(1919.0), 1918);
assert_eq!(even(1920.6), 1920);
assert_eq!(even(0.0), 2);
assert_eq!(even(1.0), 2);
}
#[test]
fn the_bitrate_follows_the_pixels_within_what_an_encoder_takes() {
let hd = h264_bitrate(1920, 1080, 30);
let uhd = h264_bitrate(3840, 2160, 30);
assert!((5_000_000..10_000_000).contains(&hd), "{hd}");
assert!(uhd > hd);
assert_eq!(
h264_bitrate(100, 100, 1),
1_500_000,
"a floor for a tiny one"
);
assert_eq!(
h264_bitrate(7680, 4320, 60),
40_000_000,
"a ceiling for a huge one"
);
}
#[test]
fn the_first_frame_is_time_zero_and_the_rest_follow_it() {
let mut placement = Placement::default();
let first = placement.frame(5_000_000_000);
assert_eq!(first.file_time, 0);
assert_eq!(placement.first_ns(), Some(5_000_000_000));
let next = placement.frame(5_033_000_000);
assert_eq!(
next.file_time, 330_000,
"33 ms is 330000 hundred-nanosecond units"
);
assert!(!next.moved);
}
#[test]
fn a_frame_not_later_than_the_last_is_moved_just_past_it() {
let mut placement = Placement::default();
placement.frame(1_000_000_000);
// a repeat stamped "now" ran a hair ahead of the next real frame
placement.repeat(1_600_000_000);
let late = placement.frame(1_599_000_000);
assert!(late.moved);
assert_eq!(late.pts_ns, 1_601_000_000);
assert!(late.file_time > 0);
}
#[test]
fn every_placement_is_strictly_later_than_the_one_before() {
let mut placement = Placement::default();
let mut last = 0;
for pts in [10, 20, 20, 15, 30, 30, 31] {
let placed = placement.frame(pts * 1_000_000);
assert!(
placed.pts_ns > last || last == 0,
"{pts}: {placed:?} after {last}"
);
last = placed.pts_ns;
}
}
#[test]
fn nothing_can_be_repeated_before_the_first_frame() {
assert_eq!(Placement::default().repeat(1_000_000_000), None);
}
#[test]
fn a_repeat_is_never_earlier_than_what_it_repeats() {
let mut placement = Placement::default();
placement.frame(2_000_000_000);
let repeat = placement.repeat(1_000_000_000).expect("there is a frame");
assert!(repeat.pts_ns > 2_000_000_000);
}
#[test]
fn a_still_display_is_kept_alive_only_while_its_source_is() {
let half = 500_000_000;
// still for long enough, source alive or unable to say
assert!(keepalive_due(2 * half, 0, None));
assert!(keepalive_due(2 * half, 0, Some(2 * half - 1)));
// not still for long enough
assert!(!keepalive_due(half - 1, 0, None));
// source silent for over two seconds: a dead capture is not made to look alive
assert!(!keepalive_due(10 * half, 0, Some(0)));
}
#[test]
fn latency_tracks_the_range_and_mean_and_says_what_clock_it_is() {
let mut latency = Latency::default();
assert!(
latency.finish().looks_like_host_clock(),
"no frames, nothing to doubt"
);
for ns in [-5_000_000, 10_000_000, 30_000_000] {
latency.record(ns);
}
let stats = latency.finish();
assert_eq!(
(stats.samples, stats.min_ns, stats.max_ns),
(3, -5_000_000, 30_000_000)
);
assert_eq!(stats.mean_ns, 11_666_666);
assert!(stats.looks_like_host_clock());
}
#[test]
fn the_frame_log_writes_frames_and_marks_repeats() {
let path =
std::env::temp_dir().join(format!("komorebi-framelog-{}.csv", std::process::id()));
let mut log = FrameLog::create(&path).expect("create");
log.frame(100, 105);
log.repeat(700, 710);
log.frame(900, 903);
log.flush();
assert_eq!(log.frames(), 2, "a repeat is not a frame");
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"0,100,105\n-,700,710\n1,900,903\n"
);
let _ = std::fs::remove_file(&path);
}
}
+860
View File
@@ -0,0 +1,860 @@
//! The Windows capture backend: Windows.Graphics.Capture into a Media Foundation fragmented MP4.
//!
//! This module exists on Windows only. [`WgcBackend`] is the [`CaptureBackend`] and
//! [`DisplayCapture`] the [`RunningCapture`]; [`crate::platform::capture`] re-exports `WgcBackend`
//! as the host's `Backend`.
//!
//! One [`DisplayCapture`] per monitor. Windows.Graphics.Capture hands frames over on a thread pool
//! thread of its own whenever the picture changes (and not otherwise: a still screen is silent),
//! each stamped with the time it was produced on the performance counter, which is the master
//! clock. Each frame is copied to a texture of ours (the capture's own is recycled as soon as the
//! frame is closed, and the encoder reads asynchronously) and written to the sink writer, which
//! encodes on the GPU where it can. The timestamps in the file are rebased to start at zero with
//! the segment; the exact master-clock time of every frame is in `frames-<display>.csv`, as on
//! macOS, and that is what is trusted for when a frame was shown.
//!
//! # How a segment is recorded
//!
//! 1. [`DisplayCapture::start`] makes a capture item for the monitor, creates one Direct3D 11
//! device that the capture and the encoder share, and creates the sink writer: H.264 in a
//! fragmented MP4 with hardware transforms enabled, throttling off, fragments about a second
//! long and a key frame about every second, at the bitrate [`h264_bitrate`] gives for the
//! size. It creates the frame log, a pool of three frames and the capture session, and starts
//! it.
//! 2. Each frame is copied to a texture the capture does not recycle, placed in time by a
//! [`Placement`], written to the
//! sink writer with the nominal duration of one frame, and logged by a [`FrameLog`]. A frame
//! whose content size differs from the display's size at the start ends the capture with an
//! error: the display changed size.
//! 3. [`RunningCapture::keepalive`] first checks whether the GPU was reset or removed, which ends
//! the capture with an error. Otherwise it repeats the last frame when the display has shown
//! nothing new for [`KEEPALIVE_AFTER`](crate::capture::segment::KEEPALIVE_AFTER)
//! ([`keepalive_due`], with a source that is always taken to be alive).
//! 4. [`RunningCapture::stop`] closes the session and the pool, then takes the lock that frame
//! callbacks hold, so one in flight finishes first. A still display has produced no frames, so
//! the segment ends with a repeat of the last frame at the segment's end time, and the sink
//! writer is finalized.
//!
//! # What this backend does not do
//!
//! - It records H.264 only: [`Codec::Hevc`] is refused when a capture starts, because Media
//! Foundation's MP4 writer does not write it.
//! - It reports no sign of life ([`RunningCapture::last_signal_mach_ns`] is `None`), because
//! Windows.Graphics.Capture sends no callbacks while the picture does not change. A stalled
//! capture is not told by silence. A display that went away is told by its capture item closing,
//! a GPU that went away by its device, and either by the display poll.
//! - It does not count idle callbacks or dropped frames: [`DisplayResult::idle_callbacks`] and
//! [`DisplayResult::dropped`] are always `0`.
//!
//! # Status
//!
//! Written from the documentation of the APIs. At the time of writing (2026-10-27) it type-checks
//! against the real bindings and passes clippy for the Windows target, but has not been run on a
//! Windows machine. What is tested is the placement and logging rules it relies on
//! ([`crate::capture::segment`]). `doctor --probe capture` is how it is checked on a real machine
//! (checkpoint W1 in `docs/dev/windows-checkpoints.md`), including a recording that is killed and
//! a static screen.
use super::CaptureBackend;
use super::CaptureConfig;
use super::CaptureStats;
use super::Codec;
use super::DisplayDescriptor;
use super::DisplayResult;
use super::EndReason;
use super::RunningCapture;
use super::frames_file_name;
use super::segment::FrameLog;
use super::segment::Latency;
use super::segment::Placement;
use super::segment::even;
use super::segment::h264_bitrate;
use super::segment::keepalive_due;
use super::video_file_name;
use crate::clock;
use crate::platform::media::pack;
use color_eyre::eyre;
use color_eyre::eyre::WrapErr;
use parking_lot::Mutex;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use windows::Foundation::TypedEventHandler;
use windows::Graphics::Capture::Direct3D11CaptureFramePool;
use windows::Graphics::Capture::GraphicsCaptureItem;
use windows::Graphics::Capture::GraphicsCaptureSession;
use windows::Graphics::DirectX::Direct3D11::IDirect3DDevice;
use windows::Graphics::DirectX::DirectXPixelFormat;
use windows::Graphics::SizeInt32;
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
use windows::Win32::Graphics::Direct3D::D3D_FEATURE_LEVEL_11_0;
use windows::Win32::Graphics::Direct3D11::D3D11_BIND_RENDER_TARGET;
use windows::Win32::Graphics::Direct3D11::D3D11_BIND_SHADER_RESOURCE;
use windows::Win32::Graphics::Direct3D11::D3D11_BOX;
use windows::Win32::Graphics::Direct3D11::D3D11_CREATE_DEVICE_BGRA_SUPPORT;
use windows::Win32::Graphics::Direct3D11::D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
use windows::Win32::Graphics::Direct3D11::D3D11_SDK_VERSION;
use windows::Win32::Graphics::Direct3D11::D3D11_TEXTURE2D_DESC;
use windows::Win32::Graphics::Direct3D11::D3D11_USAGE_DEFAULT;
use windows::Win32::Graphics::Direct3D11::D3D11CreateDevice;
use windows::Win32::Graphics::Direct3D11::ID3D11Device;
use windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext;
use windows::Win32::Graphics::Direct3D11::ID3D11Multithread;
use windows::Win32::Graphics::Direct3D11::ID3D11Texture2D;
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM;
use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC;
use windows::Win32::Graphics::Dxgi::IDXGIDevice;
use windows::Win32::Media::MediaFoundation::IMF2DBuffer;
use windows::Win32::Media::MediaFoundation::IMFAttributes;
use windows::Win32::Media::MediaFoundation::IMFDXGIDeviceManager;
use windows::Win32::Media::MediaFoundation::IMFMediaBuffer;
use windows::Win32::Media::MediaFoundation::IMFMediaType;
use windows::Win32::Media::MediaFoundation::IMFSample;
use windows::Win32::Media::MediaFoundation::IMFSinkWriter;
use windows::Win32::Media::MediaFoundation::MF_MPEG4SINK_MIN_FRAGMENT_DURATION;
use windows::Win32::Media::MediaFoundation::MF_MT_AVG_BITRATE;
use windows::Win32::Media::MediaFoundation::MF_MT_FRAME_RATE;
use windows::Win32::Media::MediaFoundation::MF_MT_FRAME_SIZE;
use windows::Win32::Media::MediaFoundation::MF_MT_INTERLACE_MODE;
use windows::Win32::Media::MediaFoundation::MF_MT_MAJOR_TYPE;
use windows::Win32::Media::MediaFoundation::MF_MT_MAX_KEYFRAME_SPACING;
use windows::Win32::Media::MediaFoundation::MF_MT_MPEG2_PROFILE;
use windows::Win32::Media::MediaFoundation::MF_MT_PIXEL_ASPECT_RATIO;
use windows::Win32::Media::MediaFoundation::MF_MT_SUBTYPE;
use windows::Win32::Media::MediaFoundation::MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS;
use windows::Win32::Media::MediaFoundation::MF_SINK_WRITER_D3D_MANAGER;
use windows::Win32::Media::MediaFoundation::MF_SINK_WRITER_DISABLE_THROTTLING;
use windows::Win32::Media::MediaFoundation::MF_TRANSCODE_CONTAINERTYPE;
use windows::Win32::Media::MediaFoundation::MFCreateAttributes;
use windows::Win32::Media::MediaFoundation::MFCreateDXGIDeviceManager;
use windows::Win32::Media::MediaFoundation::MFCreateDXGISurfaceBuffer;
use windows::Win32::Media::MediaFoundation::MFCreateMediaType;
use windows::Win32::Media::MediaFoundation::MFCreateSample;
use windows::Win32::Media::MediaFoundation::MFCreateSinkWriterFromURL;
use windows::Win32::Media::MediaFoundation::MFMediaType_Video;
use windows::Win32::Media::MediaFoundation::MFTranscodeContainerType_FMPEG4;
use windows::Win32::Media::MediaFoundation::MFVideoFormat_ARGB32;
use windows::Win32::Media::MediaFoundation::MFVideoFormat_H264;
use windows::Win32::Media::MediaFoundation::MFVideoInterlace_Progressive;
use windows::Win32::Media::MediaFoundation::eAVEncH264VProfile_High;
use windows::Win32::System::WinRT::Direct3D11::CreateDirect3D11DeviceFromDXGIDevice;
use windows::Win32::System::WinRT::Direct3D11::IDirect3DDxgiInterfaceAccess;
use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop;
use windows::core::Interface;
use windows::core::PCWSTR;
/// How many frames the capture's pool holds: enough that a frame being copied does not make the
/// next one wait.
const POOL_FRAMES: i32 = 3;
/// A fragment of the file is closed about this often (in 100 ns units): what a crash can lose.
const FRAGMENT_100NS: u64 = 10_000_000;
/// The GPU a capture works on: one device for the capture and the encoder to share.
struct Gpu {
device: ID3D11Device,
context: ID3D11DeviceContext,
/// The same device as Windows Runtime sees it, which the frame pool wants.
winrt: IDirect3DDevice,
/// Lets the encoder use the same device, so frames stay on the GPU.
manager: IMFDXGIDeviceManager,
}
impl Gpu {
fn new() -> eyre::Result<Self> {
// SAFETY: plain creation calls; every out pointer is valid for the call
unsafe {
let (mut device, mut context) = (None, None);
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&raw mut device),
None,
Some(&raw mut context),
)
.wrap_err("could not create a Direct3D 11 device")?;
let device = device.ok_or_else(|| eyre::Report::msg("no Direct3D 11 device"))?;
let context = context.ok_or_else(|| eyre::Report::msg("no Direct3D 11 context"))?;
// the capture, the copy and the encoder all use this device, from several threads
let multithread: ID3D11Multithread = device.cast()?;
let _ = multithread.SetMultithreadProtected(true);
let dxgi: IDXGIDevice = device.cast()?;
let winrt: IDirect3DDevice = CreateDirect3D11DeviceFromDXGIDevice(&dxgi)
.wrap_err("could not wrap the device for Windows.Graphics.Capture")?
.cast()?;
let (mut token, mut manager) = (0_u32, None);
MFCreateDXGIDeviceManager(&raw mut token, &raw mut manager)
.wrap_err("could not create the encoder's device manager")?;
let manager =
manager.ok_or_else(|| eyre::Report::msg("no Media Foundation device manager"))?;
manager
.ResetDevice(&device, token)
.wrap_err("could not give the device to the encoder")?;
Ok(Self {
device,
context,
winrt,
manager,
})
}
}
/// Whether the GPU has been reset or removed (a driver update, an undock that took the
/// discrete GPU): every frame after that is garbage.
fn removed(&self) -> Option<String> {
// SAFETY: no preconditions
let reason = unsafe { self.device.GetDeviceRemovedReason() };
reason
.is_err()
.then(|| format!("the GPU was reset or removed ({reason:?})"))
}
/// A texture of our own holding the top left `width` x `height` pixels of `source`, which is
/// the capture's and goes back to its pool when the frame is closed.
fn copy(
&self,
source: &ID3D11Texture2D,
width: u32,
height: u32,
) -> eyre::Result<ID3D11Texture2D> {
let description = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
..Default::default()
};
// SAFETY: the texture is created from a valid description; the copy is between textures
// of the same device, and the immediate context is protected for use from many threads
unsafe {
let mut texture = None;
self.device
.CreateTexture2D(&raw const description, None, Some(&raw mut texture))
.wrap_err("could not allocate a frame")?;
let texture = texture.ok_or_else(|| eyre::Report::msg("no frame texture"))?;
let region = D3D11_BOX {
left: 0,
top: 0,
front: 0,
right: width,
bottom: height,
back: 1,
};
self.context.CopySubresourceRegion(
&texture,
0,
0,
0,
0,
source,
0,
Some(&raw const region),
);
Ok(texture)
}
}
}
/// An encoded sink for one segment: H.264 in a fragmented MP4.
struct Writer {
sink: IMFSinkWriter,
stream: u32,
/// How long one frame at the nominal rate lasts, in 100 ns units: what the last frame of a
/// still display is given.
frame_100ns: i64,
}
fn input_type(width: u32, height: u32, fps: u32) -> eyre::Result<IMFMediaType> {
// SAFETY: attribute setters on a media type just created
unsafe {
let media = MFCreateMediaType()?;
media.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
media.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_ARGB32)?;
media.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
media.SetUINT64(&MF_MT_FRAME_SIZE, pack(width, height))?;
media.SetUINT64(&MF_MT_FRAME_RATE, pack(fps, 1))?;
media.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, pack(1, 1))?;
Ok(media)
}
}
fn output_type(width: u32, height: u32, fps: u32) -> eyre::Result<IMFMediaType> {
let bitrate = h264_bitrate(width as usize, height as usize, fps);
// SAFETY: attribute setters on a media type just created
unsafe {
let media = MFCreateMediaType()?;
media.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
media.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
media.SetUINT32(&MF_MT_AVG_BITRATE, bitrate)?;
media.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
media.SetUINT64(&MF_MT_FRAME_SIZE, pack(width, height))?;
media.SetUINT64(&MF_MT_FRAME_RATE, pack(fps, 1))?;
media.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, pack(1, 1))?;
media.SetUINT32(&MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_High.0 as u32)?;
// a key frame about every second: the fragmented file is cut into fragments at key
// frames, so this is how much of it a crash can take
media.SetUINT32(&MF_MT_MAX_KEYFRAME_SPACING, fps)?;
Ok(media)
}
}
impl Writer {
fn create(path: &Path, gpu: &Gpu, width: u32, height: u32, fps: u32) -> eyre::Result<Self> {
let fps = fps.max(1);
let wide: Vec<u16> = path
.to_string_lossy()
.encode_utf16()
.chain(Some(0))
.collect();
// SAFETY: attribute setters and creation calls with the arguments they document; `wide`
// outlives the call that reads it
unsafe {
let mut attributes: Option<IMFAttributes> = None;
MFCreateAttributes(&raw mut attributes, 6)?;
let attributes =
attributes.ok_or_else(|| eyre::Report::msg("no Media Foundation attributes"))?;
// the encoder works on the GPU's textures, on the GPU where it can
attributes.SetUnknown(&MF_SINK_WRITER_D3D_MANAGER, &gpu.manager)?;
attributes.SetUINT32(&MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, 1)?;
// a writer that holds frames back to pace the producer would hold back a recording
attributes.SetUINT32(&MF_SINK_WRITER_DISABLE_THROTTLING, 1)?;
// fragmented, so that what was written before a crash can still be played
attributes.SetGUID(
&MF_TRANSCODE_CONTAINERTYPE,
&MFTranscodeContainerType_FMPEG4,
)?;
attributes.SetUINT64(&MF_MPEG4SINK_MIN_FRAGMENT_DURATION, FRAGMENT_100NS)?;
let sink = MFCreateSinkWriterFromURL(PCWSTR(wide.as_ptr()), None, &attributes)
.wrap_err_with(|| format!("could not create the video file {}", path.display()))?;
let stream = sink
.AddStream(&output_type(width, height, fps)?)
.wrap_err("the H.264 encoder does not accept this size")?;
sink.SetInputMediaType(stream, &input_type(width, height, fps)?, None)
.wrap_err("the encoder cannot take frames of this format")?;
sink.BeginWriting()
.wrap_err("could not start writing the video")?;
Ok(Self {
sink,
stream,
frame_100ns: i64::from(10_000_000 / fps),
})
}
}
/// A sample of `buffer` shown from `time` for `duration` (both in 100 ns units).
fn sample(buffer: &IMFMediaBuffer, time: i64, duration: i64) -> eyre::Result<IMFSample> {
// SAFETY: a sample just created, given a buffer that is live
unsafe {
let sample = MFCreateSample()?;
sample.AddBuffer(buffer)?;
sample.SetSampleTime(time)?;
sample.SetSampleDuration(duration)?;
Ok(sample)
}
}
fn write(&self, buffer: &IMFMediaBuffer, time: i64) -> eyre::Result<()> {
let sample = Self::sample(buffer, time, self.frame_100ns)?;
// SAFETY: the writer is writing (`create` began it) and the sample is complete
unsafe { self.sink.WriteSample(self.stream, &sample) }
.wrap_err("the encoder rejected a frame")
}
fn finish(&self) -> eyre::Result<()> {
// SAFETY: the writer is writing; this waits for what is queued to be encoded and the file
// to be closed
unsafe { self.sink.Finalize() }.wrap_err("could not finish the video file")
}
}
/// A texture as a Media Foundation buffer the encoder can read without it leaving the GPU.
fn buffer_of(texture: &ID3D11Texture2D) -> eyre::Result<IMFMediaBuffer> {
// SAFETY: the texture is live and has one subresource
unsafe {
let buffer = MFCreateDXGISurfaceBuffer(&ID3D11Texture2D::IID, texture, 0, false)?;
// a surface buffer reports no length until it is told, and the converter in front of the
// encoder reads it
let length = buffer.cast::<IMF2DBuffer>()?.GetContiguousLength()?;
buffer.SetCurrentLength(length)?;
Ok(buffer)
}
}
/// Everything a frame callback mutates, behind a lock so that `stop` can finish the file safely.
struct State {
writer: Writer,
placement: Placement,
log: FrameLog,
latency: Latency,
/// The most recent frame, kept so that a still display can have it repeated.
last_frame: Option<IMFMediaBuffer>,
started: bool,
finished: bool,
}
struct Shared {
stats: Arc<CaptureStats>,
state: Mutex<State>,
gpu: Gpu,
/// The size the segment is recorded at, in pixels (the source's, made even).
width: u32,
height: u32,
/// The size of the display when the capture began: a frame of any other size means it changed.
source: SizeInt32,
}
// SAFETY: the Direct3D device is created multithread-protected and free-threaded, the capture's
// callbacks and Media Foundation's sink writer are agile (they work from any thread), and every
// access to the writer, the frame log and the placement is under the `state` mutex.
unsafe impl Send for Shared {}
// SAFETY: see above
unsafe impl Sync for Shared {}
/// What one frame from the capture does: copy it, place it in time, write it, log it.
fn on_frame(shared: &Shared, pool: &Direct3D11CaptureFramePool) {
let received_ns = clock::mono_now_ns();
shared.stats.callbacks.fetch_add(1, Ordering::Relaxed);
shared
.stats
.last_callback_mach_ns
.store(received_ns, Ordering::Relaxed);
// closed pools and a capture that is being stopped answer with an error: nothing to do
let Ok(frame) = pool.TryGetNextFrame() else {
return;
};
let outcome = frame_into_file(shared, &frame, received_ns);
// hands the capture's buffer back to its pool, whatever happened
let _ = frame.Close();
if let Err(error) = outcome {
shared.stats.set_error(format!("{error:#}"), received_ns);
shared.state.lock().finished = true;
}
}
fn frame_into_file(
shared: &Shared,
frame: &windows::Graphics::Capture::Direct3D11CaptureFrame,
received_ns: u64,
) -> eyre::Result<()> {
let size = frame.ContentSize()?;
if size.Width != shared.source.Width || size.Height != shared.source.Height {
// the display changed size under us (smaller would be cropped wrong, larger would be
// cut off): the manager sees that too, and starts a new segment
eyre::bail!(
"the display changed size to {}x{} while recording {}x{}",
size.Width,
size.Height,
shared.source.Width,
shared.source.Height
);
}
// the frame's time on the performance counter, in 100 ns units
let pts_ns = u64::try_from(frame.SystemRelativeTime()?.Duration)
.unwrap_or(0)
.saturating_mul(100);
let surface = frame.Surface()?;
let access: IDirect3DDxgiInterfaceAccess = surface.cast()?;
// SAFETY: a capture surface is a texture of the capture's device
let source: ID3D11Texture2D = unsafe { access.GetInterface()? };
let texture = shared.gpu.copy(&source, shared.width, shared.height)?;
let buffer = buffer_of(&texture)?;
let mut state = shared.state.lock();
if state.finished {
return Ok(());
}
if !state.started {
state.started = true;
shared
.stats
.first_pts_mach_ns
.store(pts_ns, Ordering::Relaxed);
}
let placed = state.placement.frame(pts_ns);
state.writer.write(&buffer, placed.file_time)?;
state.last_frame = Some(buffer);
state
.latency
.record(received_ns as i64 - placed.pts_ns as i64);
state.log.frame(placed.pts_ns, received_ns);
shared.stats.frames.fetch_add(1, Ordering::Relaxed);
shared
.stats
.last_pts_mach_ns
.store(placed.pts_ns, Ordering::Relaxed);
Ok(())
}
/// The monitor as a capture item.
fn item_for(display: &DisplayDescriptor) -> eyre::Result<GraphicsCaptureItem> {
let monitor = crate::platform::capture::monitor_handle(display.id).ok_or_else(|| {
eyre::Report::msg(format!(
"display {} is not available for capture (yet)",
display.id
))
})?;
let interop = windows::core::factory::<GraphicsCaptureItem, IGraphicsCaptureItemInterop>()
.wrap_err("Windows.Graphics.Capture is not available (Windows 10 1803 or later)")?;
// SAFETY: a monitor handle that was attached a moment ago
unsafe { interop.CreateForMonitor(monitor) }.wrap_err("Windows would not capture that display")
}
/// An in-progress capture of one display: one segment, recorded by Windows.Graphics.Capture into a
/// Media Foundation sink writer.
///
/// Built by [`DisplayCapture::start`] and used through [`RunningCapture`]. The video is H.264 at
/// the display's size in physical pixels, rounded to even numbers, and [`DisplayResult::scale`] is
/// `1.0`. The first error a capture reports (its item closing, the GPU being removed, a frame of
/// another size, the encoder rejecting a frame) ends the segment as
/// [`EndReason::StreamError`] when [`DisplayManager`](crate::capture::manager::DisplayManager)
/// next polls. [`stop`](RunningCapture::stop) always returns a [`DisplayResult`]: a segment that
/// never received a frame has its empty file removed and the error `no frames were captured`.
pub struct DisplayCapture {
descriptor: DisplayDescriptor,
segment: u32,
width_px: usize,
height_px: usize,
codec: Codec,
file: String,
frames_file: String,
path: std::path::PathBuf,
/// Kept alive for as long as the capture runs: its `Closed` event is how a removed display is
/// noticed.
_item: GraphicsCaptureItem,
pool: Direct3D11CaptureFramePool,
session: GraphicsCaptureSession,
shared: Arc<Shared>,
}
impl DisplayCapture {
/// Starts capturing `display` into `dir` as the given segment.
///
/// Returns once the capture session has started, without waiting for a frame:
/// `video_t0_mach_ns` is `None` until one arrives. The video is the display's size in physical
/// pixels rounded to even numbers (a frame is cropped to the top left of the display if the
/// display's size is odd). The files are `video-<display>[-<n>].mp4` and
/// `frames-<display>[-<n>].csv` in `dir`.
///
/// # Errors
///
/// Returns an error when:
///
/// - `config.codec` is [`Codec::Hevc`]: only H.264 is recorded on Windows;
/// - Windows Runtime or Media Foundation cannot be started;
/// - `display` is no longer attached (`display <id> is not available for capture (yet)`), or
/// Windows would not capture it;
/// - Windows.Graphics.Capture is not available (Windows 10 version 1803 or later is needed);
/// - Direct3D 11 cannot create a hardware device, or the device cannot be given to the capture
/// or the encoder;
/// - the video file cannot be created, the H.264 encoder does not accept the size, or the
/// encoder cannot take frames of the input format, or writing cannot begin;
/// - the frame log cannot be created in `dir`;
/// - the capture's frame pool or session cannot be created, or Windows refuses to start the
/// capture.
///
/// A failure after the sink writer was created leaves the video file behind. A caller that
/// retries gets the same segment number again ([`CaptureBackend::start`]), and so the same file
/// name.
pub fn start(
display: &DisplayDescriptor,
segment: u32,
config: &CaptureConfig,
dir: &Path,
) -> eyre::Result<Self> {
if config.codec == Codec::Hevc {
eyre::bail!(
"HEVC cannot be recorded on Windows yet (Media Foundation's MP4 writer does not \
write it); use --codec h264"
);
}
crate::platform::media::start()?;
let item = item_for(display)?;
let size = item.Size()?;
let (width, height) = (
u32::try_from(even(f64::from(size.Width)))?,
u32::try_from(even(f64::from(size.Height)))?,
);
let gpu = Gpu::new()?;
let file = video_file_name(display.id, segment);
let frames_file = frames_file_name(display.id, segment);
let path = dir.join(&file);
let writer = Writer::create(&path, &gpu, width, height, config.fps)?;
let log = FrameLog::create(&dir.join(&frames_file))
.wrap_err_with(|| format!("could not create {frames_file}"))?;
let shared = Arc::new(Shared {
stats: Arc::new(CaptureStats::default()),
state: Mutex::new(State {
writer,
placement: Placement::default(),
log,
latency: Latency::default(),
last_frame: None,
started: false,
finished: false,
}),
gpu,
width,
height,
source: size,
});
let pool = Direct3D11CaptureFramePool::CreateFreeThreaded(
&shared.gpu.winrt,
DirectXPixelFormat::B8G8R8A8UIntNormalized,
POOL_FRAMES,
SizeInt32 {
Width: size.Width,
Height: size.Height,
},
)
.wrap_err("could not create the capture's frame pool")?;
let on_arrived = shared.clone();
pool.FrameArrived(&TypedEventHandler::new(
move |pool: windows::core::Ref<Direct3D11CaptureFramePool>, _| {
if let Some(pool) = pool.as_ref() {
on_frame(&on_arrived, pool);
}
Ok(())
},
))?;
// the display going away closes its item
let on_closed = shared.clone();
item.Closed(&TypedEventHandler::new(move |_, _| {
on_closed.stats.set_error(
"the display was closed (removed, or its mode changed)".to_string(),
clock::mono_now_ns(),
);
Ok(())
}))?;
let session = pool
.CreateCaptureSession(&item)
.wrap_err("could not begin a capture session")?;
// best effort: each of these needs a newer Windows than the capture itself
let _ = session.SetIsBorderRequired(false);
let _ = session.SetIsCursorCaptureEnabled(true);
session
.StartCapture()
.wrap_err("Windows refused to start the capture")?;
Ok(Self {
descriptor: display.clone(),
segment,
width_px: width as usize,
height_px: height as usize,
codec: Codec::H264,
file,
frames_file,
path,
_item: item,
pool,
session,
shared,
})
}
fn result(
&self,
reason: Option<EndReason>,
end_ns: Option<u64>,
error: Option<String>,
) -> DisplayResult {
let state = self.shared.state.lock();
let stats = &self.shared.stats;
DisplayResult {
display: self.descriptor.clone(),
segment: self.segment,
// frames are native pixels
scale: 1.0,
width_px: self.width_px,
height_px: self.height_px,
codec: self.codec,
file: self.file.clone(),
frames_file: self.frames_file.clone(),
video_t0_mach_ns: stats.first_pts_mach_ns(),
video_end_mach_ns: end_ns,
end_reason: reason,
frames: state.log.frames(),
dropped: stats.dropped.load(Ordering::Relaxed),
// Windows.Graphics.Capture does not call back for a frame with nothing new
idle_callbacks: 0,
latency: state.latency.finish(),
error,
}
}
}
impl RunningCapture for DisplayCapture {
fn display_id(&self) -> u32 {
self.descriptor.id
}
fn segment(&self) -> u32 {
self.segment
}
fn stats(&self) -> Arc<CaptureStats> {
self.shared.stats.clone()
}
fn partial(&self) -> DisplayResult {
self.result(None, None, self.shared.stats.error())
}
/// Windows.Graphics.Capture says nothing while the picture does not change, so silence is not a
/// sign of death here and a stalled stream cannot be told by it. A display that went away is
/// told by its item closing, a GPU that went away by its device, and either by the display
/// poll.
fn last_signal_mach_ns(&self) -> Option<u64> {
None
}
fn keepalive(&self, now_ns: u64) {
if let Some(reason) = self.shared.gpu.removed() {
self.shared.stats.set_error(reason, now_ns);
self.shared.state.lock().finished = true;
return;
}
let mut state = self.shared.state.lock();
if !state.started || state.finished {
return;
}
if !keepalive_due(now_ns, state.placement.last_ns(), None) {
return;
}
let Some(last) = state.last_frame.clone() else {
return;
};
let Some(placed) = state.placement.repeat(now_ns) else {
return;
};
if let Err(error) = state.writer.write(&last, placed.file_time) {
self.shared.stats.set_error(format!("{error:#}"), now_ns);
state.finished = true;
return;
}
// not a new frame, and marked so
state.log.repeat(placed.pts_ns, now_ns);
}
fn stop(self: Box<Self>, reason: EndReason, end_mach_ns: Option<u64>) -> DisplayResult {
// no more frames: ends the session, then the pool, then waits out a callback in flight by
// taking the lock below
let _ = self.session.Close();
let _ = self.pool.Close();
let mut error = None;
let end_ns = {
let mut state = self.shared.state.lock();
state.finished = true;
state.log.flush();
// A still display produced no frames, so without an explicit end the file would stop
// at the last change and late timecodes would fall off it. A segment that ended early
// (a dead capture) ends when it died, never before its last frame.
let now = clock::mono_now_ns();
let end_ns = end_mach_ns
.unwrap_or(now)
.min(now)
.max(state.placement.last_ns());
if state.started {
if let Some(last) = state.last_frame.clone()
&& end_ns > state.placement.last_ns()
&& let Some(placed) = state.placement.repeat(end_ns)
{
match state.writer.write(&last, placed.file_time) {
Ok(()) => state.log.repeat(placed.pts_ns, now),
Err(write_error) => error = Some(format!("{write_error:#}")),
}
state.log.flush();
}
if let Err(finish_error) = state.writer.finish() {
error.get_or_insert_with(|| format!("{finish_error:#}"));
}
} else {
// never began: there is no video, and the empty file is no use to anyone
drop(state);
let _ = std::fs::remove_file(&self.path);
error = Some("no frames were captured".to_string());
return self.result(
Some(reason),
Some(end_ns),
error.or_else(|| self.shared.stats.error()),
);
}
end_ns
};
let error = error.or_else(|| self.shared.stats.error());
self.result(Some(reason), Some(end_ns), error)
}
}
/// The real Windows [`CaptureBackend`].
///
/// [`active_displays`](CaptureBackend::active_displays) is
/// [`crate::platform::capture::active_displays`], which lists the monitors from the window system
/// and needs no permission, and [`start`](CaptureBackend::start) starts a [`DisplayCapture`]. On
/// Windows, [`crate::platform::capture::Backend`] is this type.
pub struct WgcBackend;
impl CaptureBackend for WgcBackend {
fn active_displays(&self) -> eyre::Result<Vec<DisplayDescriptor>> {
crate::platform::capture::active_displays()
}
fn start(
&self,
display: &DisplayDescriptor,
segment: u32,
config: &CaptureConfig,
dir: &Path,
) -> eyre::Result<Box<dyn RunningCapture>> {
Ok(Box::new(DisplayCapture::start(
display, segment, config, dir,
)?))
}
}
+2
View File
@@ -48,6 +48,7 @@
//! | [`lock`] | telling a running recorder from one that died |
//! | [`sources`] | the sources that write raw files into the session |
//! | [`session`] | the session directory and its manifest |
//! | [`capture`] | capturing the screen into segments of video |
//!
//! # What is not supported
//!
@@ -61,6 +62,7 @@
//! [`platform`] for what each host provides.
pub mod analysis;
pub mod capture;
pub mod clock;
pub mod incidents;
pub mod komorebi_link;
@@ -0,0 +1,67 @@
//! What this host can capture: the displays, the permission to capture them, the capture backend,
//! and the duration of a recorded video.
//!
//! Screen capture on macOS is `ScreenCaptureKit` and `AVFoundation`; the code lives in
//! [`crate::capture::sck`] and this is the host-neutral name for it.
//!
//! | item | on macOS |
//! |---|---|
//! | [`supported`] | always `true` |
//! | [`has_permission`] | whether Screen Recording permission is granted; never prompts |
//! | [`ensure_permission`] | prompts for the permission if it is not granted, and fails until it is |
//! | [`active_displays`] | the displays CoreGraphics lists as active, in points |
//! | [`main_display_id`], [`main_display`] | the main display |
//! | [`media_duration_s`] | the duration of a video file, from `AVFoundation` |
//! | [`Backend`] | [`SckBackend`](crate::capture::sck::SckBackend), the [`CaptureBackend`](crate::capture::CaptureBackend) that records with `ScreenCaptureKit` |
//!
//! The same items exist on Windows. A display's `frame` is in points on a Mac and in pixels on
//! Windows ([`DisplayDescriptor::scale`] converts), and Windows needs no permission.
use crate::capture::DisplayDescriptor;
use color_eyre::eyre;
use objc2_core_graphics::CGMainDisplayID;
/// The [`CaptureBackend`](crate::capture::CaptureBackend) of this host: `ScreenCaptureKit`.
pub use crate::capture::sck::SckBackend as Backend;
/// Returns the displays attached right now, from CoreGraphics, with frames in points. It fails
/// only if CoreGraphics cannot list them; see [`crate::capture::sck::active_displays`].
pub use crate::capture::sck::active_displays;
/// Makes sure this process may capture the screen, prompting for the Screen Recording permission
/// if it must, and fails until it is granted; see [`crate::capture::sck::ensure_permission`].
pub use crate::capture::sck::ensure_permission;
/// Returns `true` if and only if the Screen Recording permission is already granted. It never
/// prompts; see [`crate::capture::sck::has_permission`].
pub use crate::capture::sck::has_permission;
/// Returns the duration of a video file in seconds as `AVFoundation` reads it, or `None` if it
/// cannot be read; see [`crate::capture::sck::media_duration_s`].
pub use crate::capture::sck::media_duration_s;
/// Returns `true` if and only if this host can capture the screen. It is always `true` on macOS
/// (and on Windows), and `false` on a host that can only investigate a session.
#[must_use]
pub fn supported() -> bool {
true
}
/// Returns the id of the display the OS treats as the main one (`CGMainDisplayID`).
///
/// This is the same id as [`DisplayDescriptor::id`] of that display in [`active_displays`]. On
/// Windows it is the primary display's, or `0` if there are none.
#[must_use]
pub fn main_display_id() -> u32 {
CGMainDisplayID()
}
/// Returns the main display among the currently attached ones.
///
/// # Errors
///
/// Returns an error if the displays cannot be listed (see [`active_displays`]), or if the main
/// display is not among them (the displays can change between the two reads).
pub fn main_display() -> eyre::Result<DisplayDescriptor> {
let main = main_display_id();
active_displays()?
.into_iter()
.find(|display| display.id == main)
.ok_or_else(|| eyre::Report::msg("the main display is not available for capture"))
}
@@ -15,6 +15,7 @@
//! re-exports its submodules under the same names on every host.
pub mod apps;
pub mod capture;
pub mod clock;
pub mod process;
pub mod shell;
+6
View File
@@ -10,6 +10,7 @@
//! | [`apps`] | which process owns a window id |
//! | [`process`] | stopping a child process |
//! | [`trace`] | streaming the OS trace into the session |
//! | [`capture`] | can we capture, which displays exist, the real [`CaptureBackend`], a video's duration |
//!
//! A third host, `unsupported`, builds on anything else so that the investigation commands (which
//! only read a session directory) still work on a Linux agent box; recording there says so.
@@ -28,6 +29,8 @@
//! compiler enforces that for the host being built, and for no other: the Windows host is built
//! from a Mac by `just check-windows` and by CI, and `unsupported` is built only on a third OS,
//! where nothing in CI builds it.
//!
//! [`CaptureBackend`]: crate::capture::CaptureBackend
#[cfg(target_os = "macos")]
mod macos;
@@ -46,7 +49,10 @@ mod win;
use win as imp;
pub use imp::apps;
pub use imp::capture;
pub use imp::clock;
#[cfg(windows)]
pub use imp::media;
pub use imp::process;
pub use imp::shell;
pub use imp::trace;
@@ -16,6 +16,93 @@ pub mod apps {
}
}
/// Screen capture, which this host cannot do.
pub mod capture {
use crate::capture::CaptureBackend;
use crate::capture::CaptureConfig;
use crate::capture::DisplayDescriptor;
use crate::capture::RunningCapture;
use color_eyre::eyre;
use std::path::Path;
fn unsupported<T>() -> eyre::Result<T> {
eyre::bail!(
"recording the screen is not supported on {}; investigating a session recorded \
elsewhere is",
std::env::consts::OS
)
}
/// Returns `false`: there is no screen to capture.
pub fn has_permission() -> bool {
false
}
/// Returns `false`: this host cannot capture the screen.
#[must_use]
pub fn supported() -> bool {
false
}
/// Fails: this host cannot capture the screen.
///
/// # Errors
///
/// Always returns an error that says recording is not supported here.
pub fn ensure_permission() -> eyre::Result<()> {
unsupported()
}
/// Fails: this host has no displays it can capture.
///
/// # Errors
///
/// Always returns an error that says recording is not supported here.
pub fn active_displays() -> eyre::Result<Vec<DisplayDescriptor>> {
unsupported()
}
/// Returns `0`: there is no main display.
#[must_use]
pub fn main_display_id() -> u32 {
0
}
/// Fails: this host has no main display.
///
/// # Errors
///
/// Always returns an error that says recording is not supported here.
pub fn main_display() -> eyre::Result<DisplayDescriptor> {
unsupported()
}
/// Returns `None`: this host has nothing to read a video's duration with.
#[must_use]
pub fn media_duration_s(_path: &Path) -> Option<f64> {
None
}
/// A capture backend that cannot capture.
pub struct Backend;
impl CaptureBackend for Backend {
fn active_displays(&self) -> eyre::Result<Vec<DisplayDescriptor>> {
active_displays()
}
fn start(
&self,
_display: &DisplayDescriptor,
_segment: u32,
_config: &CaptureConfig,
_dir: &Path,
) -> eyre::Result<Box<dyn RunningCapture>> {
unsupported()
}
}
}
/// The master clock, which here is Rust's monotonic [`Instant`].
pub mod clock {
use crate::clock::Timebase;
@@ -0,0 +1,226 @@
//! What this host can capture: the displays, the permission to capture them, the capture backend,
//! and the duration of a recorded video.
//!
//! Screen capture on Windows is Windows.Graphics.Capture into a Media Foundation writer; the code
//! lives in [`crate::capture::wgc`] and [`Backend`] is the host-neutral name for it. What this
//! module itself does is list the monitors and read their scale.
//!
//! | item | on Windows |
//! |---|---|
//! | [`supported`] | always `true` |
//! | [`has_permission`] | always `true`: there is nothing to grant |
//! | [`ensure_permission`] | always succeeds |
//! | [`active_displays`] | the monitors `EnumDisplayMonitors` lists, in physical pixels |
//! | [`main_display_id`], [`main_display`] | the primary monitor |
//! | [`media_duration_s`] | the duration of a video file, from Media Foundation |
//! | [`Backend`] | [`WgcBackend`](crate::capture::wgc::WgcBackend), the [`CaptureBackend`](crate::capture::CaptureBackend) that records with Windows.Graphics.Capture |
//!
//! The same items exist on macOS, which needs the Screen Recording permission and reports displays
//! in points. Windows-only here: [`become_per_monitor_dpi_aware`] and [`monitor_handle`].
use crate::capture::DisplayDescriptor;
use color_eyre::eyre;
use std::path::Path;
use windows::Win32::Foundation::LPARAM;
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::EnumDisplayMonitors;
use windows::Win32::Graphics::Gdi::GetMonitorInfoW;
use windows::Win32::Graphics::Gdi::HDC;
use windows::Win32::Graphics::Gdi::HMONITOR;
use windows::Win32::Graphics::Gdi::MONITORINFO;
use windows::Win32::UI::HiDpi::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
use windows::Win32::UI::HiDpi::GetDpiForMonitor;
use windows::Win32::UI::HiDpi::MDT_EFFECTIVE_DPI;
use windows::Win32::UI::HiDpi::SetProcessDpiAwarenessContext;
use windows::core::BOOL;
/// `MONITORINFO::dwFlags` for the primary monitor.
const MONITORINFOF_PRIMARY: u32 = 1;
/// Windows reports 96 dpi for a display at 100 %.
const BASE_DPI: f64 = 96.0;
/// Makes this process per-monitor DPI aware (v2), so that every size and position it reads is in
/// physical pixels, the same as komorebi's. Fails harmlessly when the process already is.
///
/// This must run before the process creates a window or reads a size: `platform::init` calls it
/// first thing in `main`, on Windows.
pub fn become_per_monitor_dpi_aware() {
// SAFETY: no pointers involved
let _ = unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
}
/// Returns `true` if and only if this host can capture the screen. It is always `true` on Windows
/// (and on macOS), and `false` on a host that can only investigate a session.
#[must_use]
pub fn supported() -> bool {
true
}
/// Returns `true` if and only if this process may capture the screen. It always may.
///
/// There is nothing to grant, because Windows lets a process capture the screen without asking.
/// macOS needs the Screen Recording permission.
#[must_use]
pub fn has_permission() -> bool {
true
}
/// Makes sure this process may capture the screen, asking the user if it must. On Windows it
/// needs nothing, so this always succeeds and asks nothing.
///
/// # Errors
///
/// Never fails on this host. (On macOS it fails until the Screen Recording permission is granted.)
pub fn ensure_permission() -> eyre::Result<()> {
Ok(())
}
struct Found {
monitor: HMONITOR,
/// Left, top, right, bottom in virtual-screen pixels.
rect: [i32; 4],
primary: bool,
}
unsafe extern "system" fn collect(
monitor: HMONITOR,
_context: HDC,
_rect: *mut RECT,
found: LPARAM,
) -> BOOL {
// SAFETY: `found` is the address of the vector `monitors` passed to `EnumDisplayMonitors`
let found = unsafe { &mut *(found.0 as *mut Vec<Found>) };
let mut info = MONITORINFO {
cbSize: size_of::<MONITORINFO>() as u32,
..Default::default()
};
// SAFETY: `info` is a valid, correctly sized out structure
if unsafe { GetMonitorInfoW(monitor, &raw mut info) }.as_bool() {
found.push(Found {
monitor,
rect: [
info.rcMonitor.left,
info.rcMonitor.top,
info.rcMonitor.right,
info.rcMonitor.bottom,
],
primary: info.dwFlags & MONITORINFOF_PRIMARY != 0,
});
}
BOOL(1)
}
fn monitors() -> Vec<Found> {
let mut found: Vec<Found> = Vec::new();
// SAFETY: `found` outlives the call and `collect` only touches it during it
let _ =
unsafe { EnumDisplayMonitors(None, None, Some(collect), LPARAM(&raw mut found as isize)) };
found
}
/// Returns the monitor with this id, if it is still attached. The id is the one
/// [`active_displays`] gives a display: the monitor's `HMONITOR`.
///
/// Windows only: the capture backend needs the handle to create a capture item for a display.
#[must_use]
pub fn monitor_handle(id: u32) -> Option<HMONITOR> {
monitors()
.into_iter()
.map(|found| found.monitor)
.find(|monitor| id_of(*monitor) == id)
}
/// A monitor's id: its `HMONITOR`, which is the id komorebi for Windows gives it too. Handles are
/// 32-bit values even in a 64-bit process.
fn id_of(monitor: HMONITOR) -> u32 {
monitor.0 as usize as u32
}
fn scale_of(monitor: HMONITOR) -> f64 {
let (mut x, mut y) = (0_u32, 0_u32);
// SAFETY: both are valid out pointers for the duration of the call
let known = unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &raw mut x, &raw mut y) };
if known.is_ok() && x > 0 {
f64::from(x) / BASE_DPI
} else {
1.0
}
}
/// Returns the displays attached right now.
///
/// A display's `id` is its `HMONITOR` as a `u32`, which is the id komorebi for Windows gives it
/// too. Its `frame` is `[left, top, width, height]` in the virtual screen's physical pixels, so
/// `width_points` and `height_points` are its width and height in pixels and `scale` is the
/// monitor's effective DPI over 96 (`1.0` if Windows will not say). `asleep` is always `false`. On
/// macOS the frame is in points and a sleeping display is reported as asleep.
///
/// # Errors
///
/// Never fails on this host: a monitor list that cannot be read is an empty list. (On macOS it
/// fails if CoreGraphics cannot list the displays.)
pub fn active_displays() -> eyre::Result<Vec<DisplayDescriptor>> {
Ok(monitors()
.into_iter()
.map(|found| {
let [left, top, right, bottom] = found.rect;
let (width, height) = (right - left, bottom - top);
DisplayDescriptor {
id: id_of(found.monitor),
frame: [
f64::from(left),
f64::from(top),
f64::from(width),
f64::from(height),
],
width_points: i64::from(width),
height_points: i64::from(height),
asleep: false,
scale: scale_of(found.monitor),
}
})
.collect())
}
/// Returns the id of the primary display (the same id as [`DisplayDescriptor::id`] of that display
/// in [`active_displays`]), or `0` if there are none.
#[must_use]
pub fn main_display_id() -> u32 {
monitors()
.iter()
.find(|found| found.primary)
.map_or(0, |found| id_of(found.monitor))
}
/// Returns the primary display.
///
/// # Errors
///
/// Returns an error if the primary display is not among the displays attached, which includes
/// there being none.
pub fn main_display() -> eyre::Result<DisplayDescriptor> {
let main = main_display_id();
active_displays()?
.into_iter()
.find(|display| display.id == main)
.ok_or_else(|| eyre::Report::msg("the primary display is not available for capture"))
}
/// Returns the playable duration of a video file as Media Foundation reads it, in seconds.
///
/// This is what recovery trusts for a file whose recorder died: the frame log is flushed on a
/// timer and can lag behind (or run ahead of) what actually reached the video. A file whose
/// duration it cannot read (some truncated ones), or that is not longer than zero, is `None`, and
/// recovery says it could not tell. On macOS the duration comes from `AVFoundation`.
///
/// Media Foundation's reader is not part of the build yet, so this is always `None`.
#[must_use]
pub fn media_duration_s(path: &Path) -> Option<f64> {
let _ = path;
None
}
/// The [`CaptureBackend`](crate::capture::CaptureBackend) of this host: Windows.Graphics.Capture.
pub use crate::capture::wgc::WgcBackend as Backend;
@@ -0,0 +1,64 @@
//! Starting Media Foundation, and packing the values it keeps in one 64-bit attribute.
//!
//! The code that writes video (`crate::capture::wgc`) and the code that reads it back
//! (`crate::av`) both need Windows Runtime and Media Foundation running: [`start`] does that once
//! for the process. [`pack`] and [`unpack`] build and take apart the attributes that hold a size
//! or a ratio.
//!
//! This module exists only on Windows. It is reached as `crate::platform::media`, and the facade
//! has no counterpart on other hosts: nothing there writes or reads video through Media
//! Foundation. Nothing tests it automatically; it is exercised by recording and by playing back
//! video on Windows.
use color_eyre::eyre;
use std::sync::OnceLock;
use windows::Win32::Media::MediaFoundation::MF_VERSION;
use windows::Win32::Media::MediaFoundation::MFSTARTUP_FULL;
use windows::Win32::Media::MediaFoundation::MFStartup;
use windows::Win32::System::WinRT::RO_INIT_MULTITHREADED;
use windows::Win32::System::WinRT::RoInitialize;
/// Starts Windows Runtime and Media Foundation for this process, once. Never shut down: the process
/// is short-lived and a restarted capture or decoder would only start it again.
///
/// The first call does the work and every later call returns the same outcome, so callers need not
/// coordinate. A thread that Windows Runtime was already initialized on, or in a different
/// apartment, is fine.
///
/// # Errors
///
/// Returns an error if Media Foundation cannot be started (`MFStartup` fails), with Windows'
/// reason. The failure is kept: later calls fail the same way without trying again.
pub fn start() -> eyre::Result<()> {
static STARTED: OnceLock<Result<(), String>> = OnceLock::new();
STARTED
.get_or_init(|| {
// SAFETY: plain initialisation calls; a thread that is already initialised, or in a
// different apartment, is fine and says so with an error that is ignored
unsafe {
let _ = RoInitialize(RO_INIT_MULTITHREADED);
MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(|error| error.to_string())
}
})
.clone()
.map_err(|error| eyre::Report::msg(format!("could not start Media Foundation: {error}")))
}
/// Packs two 32-bit values into the one 64-bit attribute Media Foundation uses for a size or a
/// ratio.
///
/// `high` goes in the upper 32 bits and `low` in the lower. The inline helpers `MFSetAttributeSize`
/// and `MFSetAttributeRatio` do this, and are not bound. A frame size is `pack(width, height)` and
/// a ratio `pack(numerator, denominator)`.
///
/// [`unpack`] undoes it: `unpack(pack(a, b)) == (a, b)` for every `a` and `b`.
#[must_use]
pub const fn pack(high: u32, low: u32) -> u64 {
((high as u64) << 32) | low as u64
}
/// Returns the two halves of such an attribute, `(high, low)`: the inverse of [`pack`].
#[must_use]
pub const fn unpack(value: u64) -> (u32, u32) {
((value >> 32) as u32, value as u32)
}
@@ -22,7 +22,9 @@
//! `docs/dev/windows-checkpoints.md`.
pub mod apps;
pub mod capture;
pub mod clock;
pub mod media;
pub mod process;
pub mod shell;
pub mod trace;
@@ -33,4 +35,5 @@ pub mod trace;
pub fn init() {
// Without this Windows lies to us about sizes: a display scaled to 150 % reads as smaller than
// it is, and komorebi's rects (which are physical pixels) would not match ours.
capture::become_per_monitor_dpi_aware();
}
+182
View File
@@ -4,6 +4,7 @@
//! the raw files, so streams stay separate and can be reconciled or re-indexed later.
use crate::analysis::flavor::Flavor;
use crate::capture::DisplayResult;
use crate::clock::Anchor;
use crate::clock::Timebase;
use crate::platform::Host;
@@ -101,6 +102,20 @@ pub struct KomorebiInfo {
pub log_dir: Option<String>,
}
/// A display's capture plus where its file sits relative to the session's video time.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DisplayEntry {
#[serde(flatten)]
/// What capturing this segment produced.
pub result: DisplayResult,
/// Seconds between the session's video t=0 and this file's first frame (>= 0). A timestamp
/// `t` in session video time is at `t - offset_s` in this segment's file.
pub offset_s: f64,
/// Session video time at which this segment ended, once it has.
#[serde(default)]
pub end_s: Option<f64>,
}
/// The outcome of the self-test that checks the video against the clocks.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Calibration {
@@ -187,6 +202,8 @@ pub struct Manifest {
pub video_t0_mach_ns: Option<u64>,
/// When video capture was stopped on the mach clock.
pub video_end_mach_ns: Option<u64>,
/// Every segment of every display's video, with its place in video time.
pub displays: Vec<DisplayEntry>,
/// The byte ranges of komorebi's log that were recorded, which together make `komorebi.log`.
pub log_segments: Vec<LogSegment>,
/// The OS trace streams that were recorded, one per file under `oslog/`.
@@ -225,6 +242,7 @@ impl Manifest {
stop: None,
video_t0_mach_ns: None,
video_end_mach_ns: None,
displays: Vec::new(),
log_segments: Vec::new(),
oslog: Vec::new(),
apps: Vec::new(),
@@ -243,6 +261,67 @@ impl Manifest {
.unwrap_or_else(|| Host::legacy_macos(self.macos_version.clone()))
}
/// Install the display segments: every segment's offset from the session's video t=0 (the
/// earliest first frame of all of them) and the session-level video start and end.
pub fn apply_segments(&mut self, results: Vec<DisplayResult>) {
self.video_t0_mach_ns = results.iter().filter_map(|r| r.video_t0_mach_ns).min();
let all_ended =
!results.is_empty() && results.iter().all(|r| r.video_end_mach_ns.is_some());
self.video_end_mach_ns = if all_ended {
results.iter().filter_map(|r| r.video_end_mach_ns).max()
} else {
None
};
let t0 = self.video_t0_mach_ns;
self.displays = results
.into_iter()
.map(|result| {
let offset_s = match (result.video_t0_mach_ns, t0) {
(Some(this), Some(first)) => this.saturating_sub(first) as f64 / 1e9,
_ => 0.0,
};
let end_s = result.video_end_mach_ns.map(|end| self.video_seconds(end));
DisplayEntry {
result,
offset_s,
end_s,
}
})
.collect();
}
/// The segment of `display` that has video at session video time `video_s`, if any. When a
/// display was unplugged or the machine slept there is a gap with no file to look in.
#[must_use]
pub fn segment_at(&self, display: u32, video_s: f64) -> Option<&DisplayEntry> {
// segment boundaries are sampled, not exact, so allow a frame either side
const SLACK_S: f64 = 0.05;
let (_, session_end) = self.video_span();
self.displays
.iter()
.filter(|entry| entry.result.display.id == display && entry.result.frames > 0)
.filter(|entry| {
video_s >= entry.offset_s - SLACK_S
&& video_s <= entry.end_s.unwrap_or(session_end) + SLACK_S
})
.max_by(|a, b| a.offset_s.total_cmp(&b.offset_s))
}
/// The distinct display ids that were recorded at any point.
#[must_use]
pub fn display_ids(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self
.displays
.iter()
.map(|entry| entry.result.display.id)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// The master clock value that is `t = 0` in video time. Falls back to the recording start if
/// no frame was ever captured, so timelines remain usable for the non-video sources.
#[must_use]
@@ -719,6 +798,109 @@ mod tests {
}
}
fn segment(display: u32, number: u32, t0: u64, end: Option<u64>) -> DisplayResult {
DisplayResult {
display: crate::capture::DisplayDescriptor {
id: display,
frame: [0.0, 0.0, 100.0, 100.0],
width_points: 100,
height_points: 100,
asleep: false,
scale: 1.0,
},
segment: number,
scale: 1.0,
width_px: 100,
height_px: 100,
codec: crate::capture::Codec::Hevc,
file: crate::capture::video_file_name(display, number),
frames_file: crate::capture::frames_file_name(display, number),
video_t0_mach_ns: Some(t0),
video_end_mach_ns: end,
end_reason: end.map(|_| crate::capture::EndReason::Stopped),
frames: 10,
dropped: 0,
idle_callbacks: 0,
latency: crate::capture::LatencyStats::default(),
error: None,
}
}
const S: u64 = 1_000_000_000;
/// Display 1 is recorded for 0-10s, unplugged, and back for 30-40s; display 2 for 2-40s.
fn docked_and_undocked() -> Manifest {
let mut manifest = manifest();
manifest.apply_segments(vec![
segment(1, 1, 100 * S, Some(110 * S)),
segment(2, 1, 102 * S, Some(140 * S)),
segment(1, 2, 130 * S, Some(140 * S)),
]);
manifest.video_end_mach_ns = Some(140 * S);
manifest
}
#[test]
fn segments_are_placed_relative_to_the_first_frame_of_any_of_them() {
let manifest = docked_and_undocked();
assert_eq!(manifest.video_t0_mach_ns, Some(100 * S));
let by_file = |file: &str| {
manifest
.displays
.iter()
.find(|entry| entry.result.file == file)
.expect("segment")
};
assert!((by_file("video-1.mp4").offset_s - 0.0).abs() < 1e-9);
assert!((by_file("video-2.mp4").offset_s - 2.0).abs() < 1e-9);
assert!((by_file("video-1-2.mp4").offset_s - 30.0).abs() < 1e-9);
assert_eq!(by_file("video-1.mp4").end_s, Some(10.0));
assert_eq!(by_file("video-1-2.mp4").end_s, Some(40.0));
assert_eq!(manifest.display_ids(), vec![1, 2]);
assert_eq!(manifest.video_span(), (0.0, 40.0));
}
#[test]
fn the_right_segment_is_found_and_a_gap_has_none() {
let manifest = docked_and_undocked();
let at = |display: u32, t: f64| {
manifest
.segment_at(display, t)
.map(|entry| entry.result.file.as_str())
};
assert_eq!(at(1, 5.0), Some("video-1.mp4"));
assert_eq!(at(1, 35.0), Some("video-1-2.mp4"));
assert_eq!(
at(1, 20.0),
None,
"display 1 was unplugged between 10s and 30s"
);
assert_eq!(at(2, 20.0), Some("video-2.mp4"), "display 2 never stopped");
assert_eq!(at(2, 1.0), None, "display 2 had not started yet");
assert_eq!(at(9, 5.0), None, "no such display");
// a frame of slack at each boundary absorbs sampling error
assert_eq!(at(1, 10.03), Some("video-1.mp4"));
assert_eq!(at(1, 10.2), None);
}
#[test]
fn a_segment_still_being_recorded_extends_to_the_end_of_the_session() {
let mut manifest = manifest();
manifest.apply_segments(vec![segment(1, 1, 100 * S, None)]);
manifest.stop = Some(Moment {
mach_ns: 160 * S,
wall_us: 0,
});
manifest.video_end_mach_ns = Some(160 * S);
assert!(manifest.segment_at(1, 50.0).is_some());
assert!(manifest.segment_at(1, 59.9).is_some());
}
#[test]
fn manifests_from_before_segments_and_status_existed_still_load() {
// exactly the shape a pre-segment recorder wrote: no `status`, no segment fields