mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-15 13:22:19 +01:00
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:
1 parent
9d35869f88
commit
fb11cb2db9
19 files changed
+6166
-1
No files matched your search
Generated
+149
@@ -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
@@ -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"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user