diff --git a/.github/workflows/verify-recording.yml b/.github/workflows/verify-recording.yml index 58159a2e7c6..8d1dea64865 100644 --- a/.github/workflows/verify-recording.yml +++ b/.github/workflows/verify-recording.yml @@ -1,8 +1,9 @@ name: Verify recording (real capture) # Records a marker stimulus through Cap's real Studio and Instant pipelines -# (ScreenCaptureKit + VideoToolbox) on macOS runners and checks every frame -# and audio marker against the host-clock truth. See +# on macOS (ScreenCaptureKit + VideoToolbox) and GitHub-hosted Windows +# (Windows Graphics Capture + Media Foundation) and checks every frame and +# audio marker against the host-clock truth. See # crates/verify/tests/recording.rs. # # Every pull request gets a "Verify recording" verdict (the job to require): @@ -332,11 +333,87 @@ jobs: ${{ github.workspace }}/verify-scratch if-no-files-found: ignore + # GitHub-hosted Windows runs jobs in the runner's interactive console + # session, where Windows Graphics Capture records the desktop; Blacksmith + # Windows VMs run jobs in session 0, which has no desktop (probe runs + # 38997642473, 38997747111). Hosted Windows has no audio endpoints, so the + # stimulus stays silent and system audio is not recorded. + real-capture-windows: + name: real-capture (windows-2022) + needs: scope + if: needs.scope.outputs.run == 'true' + runs-on: windows-2022 + timeout-minutes: 60 + env: + CAP_VERIFY_CAPTURE: display + CAP_VERIFY_REQUIRE_CAPTURE: "1" + CAP_VERIFY_TIER: ${{ needs.scope.outputs.tier }} + CAP_VERIFY_REPEAT: ${{ inputs.repeat || (github.event_name == 'schedule' && '3') || '1' }} + CAP_VERIFY_CASE: ${{ inputs.case_filter || '' }} + CAP_VERIFY_KEEP: ${{ inputs.keep_artifacts && '1' || '' }} + CAP_VERIFY_SCRATCH: ${{ github.workspace }}/verify-scratch + CAP_VERIFY_REPORT_DIR: ${{ github.workspace }}/verify-report + CAP_VERIFY_DUMP: ${{ github.workspace }}/verify-dump + CAP_VERIFY_HOST: github-hosted + CAP_VERIFY_REQUIRE_SUPPORTED: "1" + CAP_VERIFY_UNSUPPORTED_ALLOW: recording/instant/system-only + CAP_VERIFY_STIMULUS_AUDIO: "0" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup + id: setup + uses: ./.github/actions/engine-verify-setup + with: + target: x86_64-pc-windows-msvc + tier: fast + + - name: Build + shell: bash + run: | + start=$(date +%s) + cargo build --locked --profile ci-verify -p cap-verify-stimulus + cargo test --locked --profile ci-verify -p cap-verify --test recording --no-run + echo "build seconds: $(( $(date +%s) - start ))" + + - name: Save build cache + if: steps.setup.outputs.build-cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ci-verify/ + key: ${{ steps.setup.outputs.build-cache-key }} + + - name: Real-capture recording oracle + shell: bash + run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture + + - name: Summary + if: always() + shell: bash + run: python scripts/verify/summary.py --title "real capture windows-2022 ${{ needs.scope.outputs.tier }}" "$CAP_VERIFY_REPORT_DIR" >> "$GITHUB_STEP_SUMMARY" || true + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: verify-recording-windows-2022 + path: | + ${{ github.workspace }}/verify-report + ${{ github.workspace }}/verify-dump + ${{ github.workspace }}/verify-scratch + if-no-files-found: ignore + # The check to require: green when the change is out of scope or every # planned runner passed with at least the tier's case count. gate: name: Verify recording - needs: [scope, real-capture] + needs: [scope, real-capture, real-capture-windows] if: always() runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 @@ -357,6 +434,7 @@ jobs: SCOPE: ${{ needs.scope.result }} RUN: ${{ needs.scope.outputs.run }} CAPTURE: ${{ needs.real-capture.result }} + CAPTURE_WINDOWS: ${{ needs.real-capture-windows.result }} TIER: ${{ needs.scope.outputs.tier }} RUNNERS: ${{ needs.scope.outputs.runners }} CASE_FILTER: ${{ inputs.case_filter }} @@ -369,10 +447,11 @@ jobs: echo "No recording code changed: nothing to verify." | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi - if [[ "$CAPTURE" != success ]]; then - echo "real capture $CAPTURE ($TIER tier): see the runner jobs" >&2 + if [[ "$CAPTURE" != success || "$CAPTURE_WINDOWS" != success ]]; then + echo "real capture: macOS $CAPTURE, Windows $CAPTURE_WINDOWS ($TIER tier): see the runner jobs" >&2 exit 1 fi + RUNNERS=$(python3 -c 'import json, sys; print(json.dumps(json.loads(sys.argv[1]) + ["windows-2022"]))' "$RUNNERS") if [[ -n "$CASE_FILTER" ]]; then echo "case filter set: executed-case floor not applied" exit 0 diff --git a/Cargo.lock b/Cargo.lock index 2e6a9faee11..64f83487158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2157,16 +2157,19 @@ version = "0.1.0" dependencies = [ "anyhow", "cap-audio", + "cap-camera-windows", "cap-editor", "cap-enc-avfoundation", "cap-export", "cap-media-info", + "cap-mediafoundation-utils", "cap-project", "cap-recording", "cap-rendering", "cap-timestamp", "cap-verify-expect", "cidre", + "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", "flume", "futures", @@ -2178,6 +2181,7 @@ dependencies = [ "tempfile", "tokio", "tracing-subscriber", + "windows 0.60.0", "workspace-hack", ] @@ -2203,6 +2207,7 @@ dependencies = [ "scap-screencapturekit", "serde_json", "softbuffer", + "windows 0.60.0", "winit", "workspace-hack", ] diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index df542c01fbf..9c955e5b24a 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -3137,6 +3137,12 @@ pub mod synthetic { done_tx, }), }); + // A recording subscription attaches to the input's stream + // health, which opening a real input registers. + #[cfg(any(target_os = "linux", windows))] + let _ = feed + .stream_health + .insert(STREAM_ID, Arc::new(StreamHealth::new(STREAM_ID))); Self { feed: MicrophoneFeed::spawn(feed), sample_rate, diff --git a/crates/verify-stimulus/Cargo.toml b/crates/verify-stimulus/Cargo.toml index 81905b4741d..cf52f7921f1 100644 --- a/crates/verify-stimulus/Cargo.toml +++ b/crates/verify-stimulus/Cargo.toml @@ -26,3 +26,12 @@ objc2-foundation = "0.3.1" scap-screencapturekit = { path = "../scap-screencapturekit" } softbuffer = "0.4" winit = "0.30" + +[target.'cfg(windows)'.dependencies] +cpal = { workspace = true } +winit = "0.30" +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 528d9692fdf..44ff9124da5 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -1,12 +1,12 @@ //! `cap-verify-stimulus `: see `cap_verify::stimulus`. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", windows)))] fn main() { - eprintln!("cap-verify-stimulus only runs on macOS"); + eprintln!("cap-verify-stimulus only runs on macOS and Windows"); std::process::exit(2); } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] fn main() { if let Err(e) = app::run() { println!("error {e}"); @@ -14,12 +14,11 @@ fn main() { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] mod app { use std::{ fs::File, io::{BufRead, Write}, - num::NonZeroU32, path::PathBuf, sync::{ Arc, Mutex, @@ -36,6 +35,7 @@ mod app { video_code::{self, FrameMarker, PixelOrder, StreamKind}, }; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + #[cfg(target_os = "macos")] use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; use winit::{ application::ApplicationHandler, @@ -81,11 +81,16 @@ mod app { }); let sentinel = Sentinel::start(log_path.with_file_name(SENTINEL_FILE)); - use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; - let event_loop = EventLoop::::with_user_event() - .with_activation_policy(ActivationPolicy::Regular) - .with_activate_ignoring_other_apps(true) - .build()?; + #[cfg(target_os = "macos")] + let event_loop = { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + EventLoop::::with_user_event() + .with_activation_policy(ActivationPolicy::Regular) + .with_activate_ignoring_other_apps(true) + .build()? + }; + #[cfg(windows)] + let event_loop = EventLoop::::with_user_event().build()?; let proxy = event_loop.create_proxy(); spawn_stdin_reader(proxy); @@ -124,15 +129,21 @@ mod app { let observer = start_observer(app.log.clone()); let (qos, realtime) = raise_priority(app.spec.fps); app.log.write(&StimulusEvent::Priority { qos, realtime }); - let process_info = NSProcessInfo::processInfo(); - let activity = unsafe { - process_info.beginActivityWithOptions_reason( - NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical, - &NSString::from_str("Present recording verification markers on schedule"), - ) + #[cfg(target_os = "macos")] + let result = { + let process_info = NSProcessInfo::processInfo(); + let activity = unsafe { + process_info.beginActivityWithOptions_reason( + NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical, + &NSString::from_str("Present recording verification markers on schedule"), + ) + }; + let result = event_loop.run_app(&mut app); + unsafe { process_info.endActivity(&activity) }; + result }; + #[cfg(windows)] let result = event_loop.run_app(&mut app); - unsafe { process_info.endActivity(&activity) }; result?; drop(observer); drop(audio); @@ -190,10 +201,17 @@ mod app { .thread .join() .map_err(|_| anyhow::anyhow!("sentinel panicked"))?; - let info = cidre::mach::TimeBaseInfo::new(); + // Host ticks to nanoseconds: ticks * numer / denom. + #[cfg(target_os = "macos")] + let timebase = { + let info = cidre::mach::TimeBaseInfo::new(); + [u64::from(info.numer), u64::from(info.denom)] + }; + #[cfg(windows)] + let timebase = [1_000_000_000, sched::qpc_frequency()]; let mut out = serde_json::json!({ "period_secs": SENTINEL_PERIOD_SECS, - "timebase": [info.numer, info.denom], + "timebase": timebase, "first": wakes.first(), "realtime": realtime, "end": end, @@ -230,6 +248,18 @@ mod app { /// while pictures are being presented is the window server or capture /// service stalling (the input never reached the screen or the capture /// service), not the recorder. + #[cfg(windows)] + fn start_observer(log: Arc) -> Observer { + log.write(&StimulusEvent::ObserverUnavailable { + reason: "no independent capture observer on Windows".into(), + }); + Observer { + stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), + thread: None, + } + } + + #[cfg(target_os = "macos")] fn start_observer(log: Arc) -> Observer { use cidre::{ns, sc}; let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -309,7 +339,7 @@ mod app { epoch: Arc, go_at: Option<(Instant, u64)>, window: Option>, - surface: Option, Arc>>, + surface: Option, size: (u32, u32), shown: Option, ready: bool, @@ -384,21 +414,10 @@ mod app { if w == 0 || h == 0 { return; } - if surface - .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) - .is_err() - { - return; - } - let Ok(mut buffer) = surface.buffer_mut() else { - return; - }; - let (w, h) = (w as usize, h as usize); - let band = if self.spec.busy { h / 8 } else { 0 }; - { - let bytes: &mut [u8] = unsafe { - std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::(), w * h * 4) - }; + let busy = self.spec.busy; + let presented = canvas::present(surface, w, h, |bytes| { + let (w, h) = (w as usize, h as usize); + let band = if busy { h / 8 } else { 0 }; let stride = w * 4; let marker = FrameMarker::new(StreamKind::Screen, 0, index); video_code::draw_marker( @@ -419,8 +438,8 @@ mod app { (salt << 1) | 1, ); } - } - if buffer.present().is_ok() { + }); + if presented { let host = host_now(); if self.shown != Some(index) { self.log.write(&StimulusEvent::Frame { @@ -437,6 +456,155 @@ mod app { } } + /// Where the stimulus draws BGRA pictures and presents them. + #[cfg(target_os = "macos")] + mod canvas { + use super::*; + use std::num::NonZeroU32; + + pub type Canvas = softbuffer::Surface, Arc>; + + pub fn new(window: &Arc) -> Result { + softbuffer::Context::new(window.clone()) + .and_then(|context| softbuffer::Surface::new(&context, window.clone())) + .map_err(|e| e.to_string()) + } + + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { + if canvas + .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) + .is_err() + { + return false; + } + let Ok(mut buffer) = canvas.buffer_mut() else { + return false; + }; + let bytes: &mut [u8] = unsafe { + std::slice::from_raw_parts_mut( + buffer.as_mut_ptr().cast::(), + w as usize * h as usize * 4, + ) + }; + paint(bytes); + buffer.present().is_ok() + } + } + + /// A layered window updated with `UpdateLayeredWindow`, which hands the + /// compositor a whole picture. A GDI blit into the window's redirection + /// surface (softbuffer) could be composited, and so captured by Windows + /// Graphics Capture, half done: recordings showed markers whose top rows + /// were the new picture and bottom rows the old one (run 39000141528), + /// also when the blit started right after a composition pass (run + /// 39004015045). Hosted runners' Hyper-V display rejected a flip-model + /// DXGI swap chain (run 39002964457). + #[cfg(windows)] + mod canvas { + use super::*; + use windows::Win32::{ + Foundation::{COLORREF, HWND, POINT, SIZE}, + Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleDC, CreateDIBSection, + DIB_RGB_COLORS, DeleteObject, GetDC, HBITMAP, HDC, SelectObject, + }, + UI::WindowsAndMessaging::{ + GWL_EXSTYLE, GetWindowLongPtrW, SetWindowLongPtrW, ULW_OPAQUE, UpdateLayeredWindow, + WS_EX_LAYERED, + }, + }; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + pub struct Canvas { + hwnd: HWND, + screen: HDC, + memory: HDC, + bitmap: Option, + bits: *mut u8, + size: (u32, u32), + } + + pub fn new(window: &Arc) -> Result { + let hwnd = match window.window_handle().map_err(|e| e.to_string())?.as_raw() { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut core::ffi::c_void), + other => return Err(format!("not a Win32 window: {other:?}")), + }; + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED.0 as isize); + let screen = GetDC(None); + let memory = CreateCompatibleDC(Some(screen)); + Ok(Canvas { + hwnd, + screen, + memory, + bitmap: None, + bits: std::ptr::null_mut(), + size: (0, 0), + }) + } + } + + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { + if canvas.size != (w, h) || canvas.bitmap.is_none() { + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: w as i32, + biHeight: -(h as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits = std::ptr::null_mut(); + let Ok(bitmap) = (unsafe { + CreateDIBSection( + Some(canvas.memory), + &info, + DIB_RGB_COLORS, + &mut bits, + None, + 0, + ) + }) else { + return false; + }; + unsafe { + SelectObject(canvas.memory, bitmap.into()); + if let Some(old) = canvas.bitmap.replace(bitmap) { + let _ = DeleteObject(old.into()); + } + } + canvas.bits = bits.cast(); + canvas.size = (w, h); + } + let len = w as usize * h as usize * 4; + paint(unsafe { std::slice::from_raw_parts_mut(canvas.bits, len) }); + let size = SIZE { + cx: w as i32, + cy: h as i32, + }; + let origin = POINT { x: 0, y: 0 }; + unsafe { + UpdateLayeredWindow( + canvas.hwnd, + Some(canvas.screen), + None, + Some(&size), + Some(canvas.memory), + Some(&origin), + COLORREF(0), + None, + ULW_OPAQUE, + ) + } + .is_ok() + } + } + /// Achromatic 16 px blocks whose levels change every frame. fn noise_band(data: &mut [u8], w: usize, h: usize, index: u32, salt: u64) { let stride = w * 4; @@ -485,9 +653,7 @@ mod app { }; let size = window.inner_size(); self.size = (size.width, size.height); - let surface = softbuffer::Context::new(window.clone()) - .and_then(|context| softbuffer::Surface::new(&context, window.clone())); - match surface { + match canvas::new(&window) { Ok(s) => self.surface = Some(s), Err(e) => { self.error = Some(format!("surface: {e}")); @@ -694,6 +860,7 @@ mod app { epoch: Arc, log: Arc, ) -> Result { + #[cfg(target_os = "macos")] use cpal::host::coreaudio::StreamInstantExt; let host = cpal::default_host(); @@ -760,7 +927,17 @@ mod app { .build_output_stream( &config.config(), move |data: &mut [f32], info: &cpal::OutputCallbackInfo| { - state.fill(data, info.timestamp().callback.as_host_time()); + #[cfg(target_os = "macos")] + let callback_host = info.timestamp().callback.as_host_time(); + // WASAPI stream instants are QueryPerformanceCounter + // readings in 100 ns units. + #[cfg(windows)] + let callback_host = { + use cpal::host::wasapi::StreamInstantExt; + let units = i128::from(info.timestamp().callback.as_performance_counter()); + (units * i128::from(cap_verify::sched::qpc_frequency()) / 10_000_000) as u64 + }; + state.fill(data, callback_host); }, |e| eprintln!("stimulus audio error: {e}"), None, diff --git a/crates/verify/Cargo.toml b/crates/verify/Cargo.toml index 4392a72d173..453ddfbc8d0 100644 --- a/crates/verify/Cargo.toml +++ b/crates/verify/Cargo.toml @@ -40,3 +40,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(target_os = "macos")'.dev-dependencies] scap-targets = { path = "../scap-targets" } + +[target.'cfg(windows)'.dev-dependencies] +scap-targets = { path = "../scap-targets" } +cap-camera-windows = { path = "../camera-windows" } +cap-mediafoundation-utils = { path = "../mediafoundation-utils" } +cpal = { workspace = true } +windows = { workspace = true, features = ["Win32_Media_MediaFoundation"] } diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index 32dedfe7a42..ecfd79927d9 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -235,9 +235,35 @@ link = "" [[xfail]] case = "recording/instant/late-mic" class = "sync-screen-mic" -platforms = ["macos"] +platforms = ["macos", "windows"] bound = { count = 1, offset-ms = [900, 1100] } -reason = "NEW: a microphone whose first sample arrives 1.0 s after the recording starts becomes audio zero, so all audio plays early by its start-up delay minus the screen's (screen late against the mic by 962 to 1040 ms on runners, every run). Bound: the mic's 1.0 s delay within 100 ms" +reason = "NEW: a microphone whose first sample arrives 1.0 s after the recording starts becomes audio zero, so all audio plays early by its start-up delay minus the screen's (screen late against the mic by 962 to 1040 ms on macOS and Windows runners, every run). Bound: the mic's 1.0 s delay within 100 ms" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "timestamps" +platforms = ["windows"] +bound = { count = 2 } +reason = "NEW: Windows camera encoder frame pacing (WindowsCameraMuxer, crates/recording/src/output_pipeline/win.rs): when no frame arrives within one frame interval it re-encodes the last frame stamped one interval later, the slot the late real frame is stamped for, so the real frame lands on the same timestamp and the muxer moves it by one 90 kHz tick (run 39000141528: 16 duplicated frames, pts 0.266667 then 0.266678). Also in cap-v0.6.0" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "sync-camera-mic" +platforms = ["windows"] +flaky = true +bound = { count = 1, camera-mic-ms = [-70, 0] } +reason = "NEW: same Windows camera frame pacing: the paced duplicates shift the camera track's frames against their capture times (camera vs mic -38.3 ms in run 39000141528, -18.4 ms, inside the limit, in run 39004015045). Bound: two frame periods early" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "camera-clock" +platforms = ["windows"] +flaky = true +bound = { count = 1, camera-mic-ms = [-70, 0] } +reason = "NEW: same Windows camera frame pacing, past the drift anchor (camera vs mic -38.3 ms in run 39000141528, -18.4 ms in run 39004015045). Bound: two frame periods early" link = "" [[xfail]] diff --git a/crates/verify/src/lib.rs b/crates/verify/src/lib.rs index 2460603f81e..7a31487d045 100644 --- a/crates/verify/src/lib.rs +++ b/crates/verify/src/lib.rs @@ -15,6 +15,9 @@ pub mod pipeline; pub mod report; #[cfg(target_os = "macos")] pub mod sched; +#[cfg(windows)] +#[path = "sched_windows.rs"] +pub mod sched; pub mod sentinel; pub mod stimulus; pub mod video_code; diff --git a/crates/verify/src/sched_windows.rs b/crates/verify/src/sched_windows.rs new file mode 100644 index 00000000000..945967c8144 --- /dev/null +++ b/crates/verify/src/sched_windows.rs @@ -0,0 +1,234 @@ +//! Real-time scheduling for the verification suite's input sources, Windows. +//! +//! The same interface as the macOS module. Windows has no time-constraint +//! policy: an input thread asks for `THREAD_PRIORITY_TIME_CRITICAL` (the top +//! of its process's priority class, where audio and capture callbacks run) +//! and a 1 ms system timer resolution, and the priority is read back. Host +//! ticks are QueryPerformanceCounter ticks, the clock Cap stamps Windows +//! capture with (`Timestamp::PerformanceCounter`). + +use serde::{Deserialize, Serialize}; + +/// Milliseconds. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Constraint { + pub period_ms: f64, + pub computation_ms: f64, + pub constraint_ms: f64, + pub preemptible: bool, +} + +/// A thread's scheduling state: `GetThreadPriority` in every priority field +/// and the thread's CPU time. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadState { + pub policy: i32, + pub base_pri: i32, + pub cur_pri: i32, + pub max_pri: i32, + pub cpu_us: u64, +} + +/// `THREAD_PRIORITY_TIME_CRITICAL`. +pub const REALTIME_PRIORITY: i32 = 15; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Realtime { + pub requested: Constraint, + /// `timeBeginPeriod(1)` (`TIMERR_NOERROR` is 0). + pub qos_status: i32, + /// 0 when `SetThreadPriority` succeeded, else `GetLastError`. + pub policy_status: i32, + pub readback_status: i32, + pub readback_default: bool, + pub granted: Option, + pub state: Option, +} + +impl Realtime { + pub fn obtained(&self) -> Result<(), String> { + if self.policy_status != 0 { + return Err(format!( + "SetThreadPriority(TIME_CRITICAL) failed: {}", + self.policy_status + )); + } + state_is_realtime(self.state) + } +} + +pub fn state_is_realtime(state: Option) -> Result<(), String> { + match state { + Some(state) if state.base_pri >= REALTIME_PRIORITY => Ok(()), + Some(state) => Err(format!( + "thread priority {} is below time-critical ({REALTIME_PRIORITY})", + state.base_pri + )), + None => Err("GetThreadPriority failed".into()), + } +} + +const THREAD_QUERY_LIMITED_INFORMATION: u32 = 0x0800; +const THREAD_PRIORITY_ERROR_RETURN: i32 = 0x7fff_ffff; +const CREATE_WAITABLE_TIMER_HIGH_RESOLUTION: u32 = 0x0000_0002; +const TIMER_ALL_ACCESS: u32 = 0x001F_0003; +const INFINITE: u32 = 0xFFFF_FFFF; + +unsafe extern "system" { + fn GetCurrentThread() -> isize; + fn GetCurrentThreadId() -> u32; + fn GetLastError() -> u32; + fn SetThreadPriority(thread: isize, priority: i32) -> i32; + fn GetThreadPriority(thread: isize) -> i32; + fn OpenThread(access: u32, inherit: i32, id: u32) -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetThreadTimes( + thread: isize, + creation: *mut u64, + exit: *mut u64, + kernel: *mut u64, + user: *mut u64, + ) -> i32; + fn QueryPerformanceCounter(count: *mut i64) -> i32; + fn QueryPerformanceFrequency(frequency: *mut i64) -> i32; + fn CreateWaitableTimerExW( + attributes: *const core::ffi::c_void, + name: *const u16, + flags: u32, + access: u32, + ) -> isize; + fn SetWaitableTimer( + timer: isize, + due: *const i64, + period: i32, + completion: *const core::ffi::c_void, + argument: *const core::ffi::c_void, + resume: i32, + ) -> i32; + fn WaitForSingleObject(handle: isize, millis: u32) -> u32; + fn SwitchToThread() -> i32; +} + +#[link(name = "winmm")] +unsafe extern "system" { + fn timeBeginPeriod(period: u32) -> u32; +} + +pub fn current_thread() -> u32 { + unsafe { GetCurrentThreadId() } +} + +pub fn qpc_now() -> u64 { + let mut count = 0i64; + unsafe { QueryPerformanceCounter(&mut count) }; + count as u64 +} + +pub fn qpc_frequency() -> u64 { + static FREQUENCY: std::sync::OnceLock = std::sync::OnceLock::new(); + *FREQUENCY.get_or_init(|| { + let mut frequency = 0i64; + unsafe { QueryPerformanceFrequency(&mut frequency) }; + frequency.max(1) as u64 + }) +} + +/// Asks for the time-critical priority and a 1 ms timer resolution, then +/// reads the priority back. +pub fn request_realtime(period_secs: f64, computation_secs: f64) -> Realtime { + let requested = Constraint { + period_ms: period_secs * 1e3, + computation_ms: computation_secs * 1e3, + constraint_ms: period_secs * 1e3, + preemptible: true, + }; + let qos_status = unsafe { timeBeginPeriod(1) } as i32; + let policy_status = if unsafe { SetThreadPriority(GetCurrentThread(), REALTIME_PRIORITY) } != 0 + { + 0 + } else { + unsafe { GetLastError() as i32 } + }; + let state = thread_state(current_thread()); + let granted = state + .filter(|state| state.base_pri >= REALTIME_PRIORITY) + .map(|_| requested); + Realtime { + requested, + qos_status, + policy_status, + readback_status: if state.is_some() { 0 } else { -1 }, + readback_default: granted.is_none(), + granted, + state, + } +} + +pub fn thread_state(thread: u32) -> Option { + let handle = unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION, 0, thread) }; + if handle == 0 { + return None; + } + let priority = unsafe { GetThreadPriority(handle) }; + let (mut creation, mut exit, mut kernel, mut user) = (0u64, 0u64, 0u64, 0u64); + let times = unsafe { GetThreadTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }; + unsafe { CloseHandle(handle) }; + (priority != THREAD_PRIORITY_ERROR_RETURN).then_some(ThreadState { + policy: 0, + base_pri: priority, + cur_pri: priority, + max_pri: REALTIME_PRIORITY, + // FILETIME counts 100 ns intervals. + cpu_us: if times != 0 { (kernel + user) / 10 } else { 0 }, + }) +} + +struct Timer(isize); + +impl Drop for Timer { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { CloseHandle(self.0) }; + } + } +} + +thread_local! { + static TIMER: Timer = Timer(unsafe { + CreateWaitableTimerExW( + std::ptr::null(), + std::ptr::null(), + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + TIMER_ALL_ACCESS, + ) + }); +} + +/// How long before the deadline the timer wait ends and spinning starts. +const SPIN_SECS: f64 = 0.000_25; + +/// Blocks until the host clock reaches `deadline` (QueryPerformanceCounter +/// ticks): a high-resolution waitable timer for most of the wait, then a +/// yielding spin for the last quarter millisecond. +pub fn wait_until(deadline: u64) { + let frequency = qpc_frequency() as f64; + let remaining = |now: u64| (deadline as f64 - now as f64) / frequency; + let left = remaining(qpc_now()); + if left > SPIN_SECS { + TIMER.with(|&Timer(timer)| { + if timer != 0 { + // Negative: relative, in 100 ns intervals. + let due = -(((left - SPIN_SECS) * 1e7) as i64).max(1); + unsafe { + if SetWaitableTimer(timer, &due, 0, std::ptr::null(), std::ptr::null(), 0) != 0 + { + WaitForSingleObject(timer, INFINITE); + } + } + } + }); + } + while remaining(qpc_now()) > 0.0 { + unsafe { SwitchToThread() }; + } +} diff --git a/crates/verify/src/stimulus.rs b/crates/verify/src/stimulus.rs index 0820d75b234..d43e7eef6d9 100644 --- a/crates/verify/src/stimulus.rs +++ b/crates/verify/src/stimulus.rs @@ -130,7 +130,7 @@ pub const SENTINEL_PERIOD_SECS: f64 = 0.002; /// Every wake of the stimulus's real-time sentinel thread (host ticks) and /// the scheduling it ran under. -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] #[derive(Clone, Debug)] pub struct SentinelLog { pub realtime: crate::sched::Realtime, @@ -138,7 +138,7 @@ pub struct SentinelLog { pub wakes: Vec, } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] impl SentinelLog { pub fn read(path: &std::path::Path) -> anyhow::Result { #[derive(serde::Deserialize)] @@ -190,10 +190,27 @@ pub fn host_add_secs(a: u64, secs: f64) -> u64 { (a as f64 + secs * 1e9 * f64::from(info.denom) / f64::from(info.numer)).round() as u64 } -#[cfg(target_os = "macos")] +/// QueryPerformanceCounter ticks, the clock Windows capture is stamped with. +#[cfg(windows)] +pub fn host_now() -> u64 { + crate::sched::qpc_now() +} + +/// Seconds between two host times (`a - b`). +#[cfg(windows)] +pub fn host_secs(a: u64, b: u64) -> f64 { + (a as f64 - b as f64) / crate::sched::qpc_frequency() as f64 +} + +#[cfg(windows)] +pub fn host_add_secs(a: u64, secs: f64) -> u64 { + (a as f64 + secs * crate::sched::qpc_frequency() as f64).round() as u64 +} + +#[cfg(any(target_os = "macos", windows))] pub use process::StimulusProcess; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] mod process { use std::{ io::{BufRead, BufReader, Write}, diff --git a/crates/verify/src/video_code.rs b/crates/verify/src/video_code.rs index 399b8ad8dc0..f0e5733fd28 100644 --- a/crates/verify/src/video_code.rs +++ b/crates/verify/src/video_code.rs @@ -267,6 +267,30 @@ pub fn rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (f64, f64, f64) { ) } +/// Tightly packed limited-range NV12 (BT.601) of an RGBA picture: the Y +/// plane, then interleaved Cb/Cr sampled at each 2x2 block's top-left pixel. +pub fn rgba_to_nv12(rgba: &[u8], width: usize, height: usize) -> Vec { + let ycc = |x: usize, y: usize| { + let i = (y * width + x) * 4; + rgb_to_ycbcr(rgba[i], rgba[i + 1], rgba[i + 2]) + }; + let mut out = Vec::with_capacity(width * height * 3 / 2); + for y in 0..height { + for x in 0..width { + let (l, _, _) = ycc(x, y); + out.push((16.0 + l * 219.0 / 255.0).round() as u8); + } + } + for y in 0..height / 2 { + for x in 0..width / 2 { + let (_, cb, cr) = ycc(x * 2, y * 2); + out.push((128.0 + cb * 224.0 / 255.0).round() as u8); + out.push((128.0 + cr * 224.0 / 255.0).round() as u8); + } + } + out +} + /// Read access to a decoded picture in luma + centred chroma. pub trait PixelSource { fn width(&self) -> usize; diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index 5ad4ff5d698..6bc451bff06 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -14,7 +14,7 @@ //! `off` skips. Without Screen Recording permission the cases skip unless //! `CAP_VERIFY_REQUIRE_CAPTURE=1`. -#![cfg(target_os = "macos")] +#![cfg(any(target_os = "macos", windows))] use std::{ collections::BTreeMap, @@ -29,7 +29,7 @@ use std::{ use cap_media_info::{RawVideoFormat, VideoInfo}; use cap_project::{InstantRecordingMeta, RecordingMeta, RecordingMetaInner, StudioRecordingMeta}; use cap_recording::{ - NativeCameraFrame, RecordingDefaults, SendableShareableContent, StudioQuality, + NativeCameraFrame, RecordingDefaults, StudioQuality, feeds::{ Admission, camera::synthetic::SyntheticCamera, microphone::synthetic::SyntheticMicrophone, }, @@ -38,10 +38,11 @@ use cap_recording::{ screen_capture::ScreenCaptureTarget, studio_recording, }; -use cap_timestamp::{MachAbsoluteTimestamp, Timestamp}; +#[cfg(target_os = "macos")] +use cap_verify::fixture_avf; use cap_verify::{ audio_code::{self, AudioSource, MarkerPlacement}, - fixture_avf, media, + media, report::{self, Case, Tier}, sched, stimulus::{ @@ -51,6 +52,89 @@ use cap_verify::{ video_code::{self, FrameMarker, Readout, StreamKind}, }; +use platform::{Shareable, host_timestamp}; + +/// What differs between the macOS and Windows hosts: the capture permission +/// check, the host clock the sources are stamped with and thread priority. +#[cfg(target_os = "macos")] +mod platform { + use cap_timestamp::{MachAbsoluteTimestamp, Timestamp}; + + pub type Shareable = cap_recording::SendableShareableContent; + + /// ScreenCaptureKit's shareable content, which also checks the Screen + /// Recording permission. + pub async fn shareable() -> anyhow::Result { + cidre::sc::ShareableContent::current() + .await + .map(Shareable::from) + .map_err(|e| { + anyhow::anyhow!("ScreenCaptureKit unavailable ({e:?}); grant Screen Recording") + }) + } + + pub fn host_timestamp(ticks: u64) -> Timestamp { + Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(ticks)) + } + + /// Host ticks to nanoseconds: ticks * numer / denom. + pub fn timebase() -> [u64; 2] { + let info = cidre::mach::TimeBaseInfo::new(); + [u64::from(info.numer), u64::from(info.denom)] + } + + pub fn raise_thread_qos() { + const QOS_CLASS_USER_INITIATED: u32 = 0x19; + unsafe extern "C" { + fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32; + } + unsafe { pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) }; + } + + /// Every host has an output device for the system audio stimulus. + pub fn system_audio_output() -> bool { + true + } +} + +#[cfg(windows)] +mod platform { + use cap_timestamp::{PerformanceCounterTimestamp, Timestamp}; + + pub type Shareable = (); + + /// Windows Graphics Capture needs no permission; recording itself fails + /// when the session has no desktop. + pub async fn shareable() -> anyhow::Result { + Ok(()) + } + + pub fn host_timestamp(ticks: u64) -> Timestamp { + Timestamp::PerformanceCounter(PerformanceCounterTimestamp::new(ticks as i64)) + } + + /// Host ticks to nanoseconds: ticks * numer / denom. + pub fn timebase() -> [u64; 2] { + [1_000_000_000, cap_verify::sched::qpc_frequency()] + } + + pub fn raise_thread_qos() { + const THREAD_PRIORITY_ABOVE_NORMAL: i32 = 1; + unsafe extern "system" { + fn GetCurrentThread() -> isize; + fn SetThreadPriority(thread: isize, priority: i32) -> i32; + } + unsafe { SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL) }; + } + + /// Hosted Windows runners run the audio service with no endpoints: there + /// is nothing to play the system audio stimulus on or to loop back. + pub fn system_audio_output() -> bool { + use cpal::traits::HostTrait; + cpal::default_host().default_output_device().is_some() + } +} + static ATTEMPT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); const MIC_RATE: u32 = 48_000; @@ -395,13 +479,8 @@ fn env_flag(name: &str) -> bool { /// high priority; at utility priority a loaded runner delays them and their /// lateness shows up as recording timing errors. Run the runtime at the /// priority the product itself asks for its recording threads. -#[cfg(target_os = "macos")] fn raise_thread_qos() { - const QOS_CLASS_USER_INITIATED: u32 = 0x19; - unsafe extern "C" { - fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32; - } - unsafe { pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) }; + platform::raise_thread_qos(); } fn runtime() -> tokio::runtime::Runtime { @@ -433,10 +512,10 @@ fn real_capture() { return; }; let rt = runtime(); - let shareable = match rt.block_on(cidre::sc::ShareableContent::current()) { - Ok(content) => SendableShareableContent::from(content), + let shareable = match rt.block_on(platform::shareable()) { + Ok(content) => content, Err(e) => { - let message = format!("ScreenCaptureKit unavailable ({e:?}); grant Screen Recording"); + let message = format!("{e:#}"); if env_flag("CAP_VERIFY_REQUIRE_CAPTURE") { panic!("{message}"); } @@ -706,10 +785,7 @@ fn start_feeds( } } trace.handoff(n as u32, delivered, wake, pri, || { - mic.try_push( - chunk, - Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(capture)), - ) + mic.try_push(chunk, host_timestamp(capture)) }); } trace.end = sched::thread_state(thread); @@ -982,7 +1058,6 @@ fn persist_sources( .map(|d| PathBuf::from(d).join("sources")) .unwrap_or_else(|| fallback.to_path_buf()); let path = dir.join(format!("{stem}.json")); - let info = cidre::mach::TimeBaseInfo::new(); let sources: Vec = rec .sources .iter() @@ -1013,7 +1088,7 @@ fn persist_sources( .collect(); let value = serde_json::json!({ "attempt": stem, - "timebase": [info.numer, info.denom], + "timebase": platform::timebase(), "reference": reference, "window_secs": [rec.started, rec.stopped], "columns": ["index", "due", "wake", "built", "pushed", "admission", "pri"], @@ -1027,16 +1102,43 @@ fn persist_sources( path } +#[cfg(target_os = "macos")] fn camera_frame(slot: u32, capture: u64) -> Option { let (w, h) = CAMERA_SIZE; let rgba = video_code::marker_rgba(FrameMarker::new(StreamKind::Camera, 0, slot), w, h); let sample_buf = camera_sample(&rgba, w, h, capture).ok()?; Some(NativeCameraFrame { sample_buf, - timestamp: Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(capture)), + timestamp: host_timestamp(capture), + }) +} + +/// An NV12 frame in a Media Foundation memory buffer, as the Windows camera +/// feed hands native frames to the encoder. +#[cfg(windows)] +fn camera_frame(slot: u32, capture: u64) -> Option { + use cap_mediafoundation_utils::IMFMediaBufferExt; + use windows::Win32::Media::MediaFoundation::MFCreateMemoryBuffer; + + let (w, h) = CAMERA_SIZE; + let rgba = video_code::marker_rgba(FrameMarker::new(StreamKind::Camera, 0, slot), w, h); + let nv12 = video_code::rgba_to_nv12(&rgba, w, h); + let buffer = unsafe { MFCreateMemoryBuffer(nv12.len() as u32) }.ok()?; + buffer.lock_for_write().ok()?.copy_from_slice(&nv12); + unsafe { buffer.SetCurrentLength(nv12.len() as u32) }.ok()?; + #[allow(clippy::arc_with_non_send_sync)] + let buffer = Arc::new(std::sync::Mutex::new(buffer)); + Some(NativeCameraFrame { + buffer, + pixel_format: cap_camera_windows::PixelFormat::NV12, + width: w as u32, + height: h as u32, + is_bottom_up: false, + timestamp: host_timestamp(capture), }) } +#[cfg(target_os = "macos")] fn camera_sample( rgba: &[u8], w: usize, @@ -1067,21 +1169,14 @@ enum Handle { /// reach the recording. async fn capture_setup( capture: CaptureMode, -) -> anyhow::Result<( - ScreenCaptureTarget, - Vec, - SendableShareableContent, -)> { +) -> anyhow::Result<(ScreenCaptureTarget, Vec, Shareable)> { let stimulus_window = find_window(WINDOW_TITLE); let excluded = scap_targets::Window::list() .into_iter() .map(|w| w.id()) .filter(|id| Some(id) != stimulus_window.as_ref()) .collect(); - let shareable = cidre::sc::ShareableContent::current() - .await - .map_err(|e| anyhow::anyhow!("shareable content: {e:?}"))? - .into(); + let shareable = platform::shareable().await?; let target = match capture { CaptureMode::Display => ScreenCaptureTarget::Display { id: scap_targets::Display::primary().id(), @@ -1105,19 +1200,50 @@ fn studio_builder( system_audio: bool, ) -> (studio_recording::ActorBuilder, u32) { let defaults = RecordingDefaults::default(); - let builder = defaults - .apply_to_studio_builder( - studio_recording::Actor::builder(project, target).with_system_audio(system_audio), - camera, - None, - ) - .with_excluded_windows(excluded) + let builder = defaults.apply_to_studio_builder( + studio_recording::Actor::builder(project, target).with_system_audio(system_audio), + camera, + None, + ); + // Windows Graphics Capture records the display without per-window + // exclusion; the stimulus covers it full screen. + #[cfg(target_os = "macos")] + let builder = builder.with_excluded_windows(excluded); + #[cfg(windows)] + let _ = excluded; + let builder = builder .with_keyboard_capture(false) .with_fragmented(fragmented) .with_quality(StudioQuality::Balanced); (builder, defaults.studio_max_fps(camera, None)) } +async fn build_studio( + builder: studio_recording::ActorBuilder, + shareable: &Shareable, +) -> anyhow::Result { + #[cfg(target_os = "macos")] + return builder.build(Some(shareable.clone())).await; + #[cfg(windows)] + { + let () = *shareable; + builder.build().await + } +} + +async fn build_instant( + builder: instant_recording::ActorBuilder, + shareable: &Shareable, +) -> anyhow::Result { + #[cfg(target_os = "macos")] + return builder.build(Some(shareable.clone())).await; + #[cfg(windows)] + { + let () = *shareable; + builder.build().await + } +} + fn stimulus_binary() -> PathBuf { std::env::var_os("CAP_VERIFY_STIMULUS_BIN") .map(PathBuf::from) @@ -1129,7 +1255,7 @@ fn stimulus_binary() -> PathBuf { .parent() .and_then(|deps| deps.parent()) .expect("target profile directory") - .join("cap-verify-stimulus"); + .join(format!("cap-verify-stimulus{}", std::env::consts::EXE_SUFFIX)); assert!( path.exists(), "{} not found: build it first with `cargo build -p cap-verify-stimulus` (same profile), or set CAP_VERIFY_STIMULUS_BIN", @@ -1171,7 +1297,7 @@ struct Recorded { async fn run_case( spec: &Spec, capture: CaptureMode, - shareable: &SendableShareableContent, + shareable: &Shareable, case: &mut Case, ) -> anyhow::Result<()> { let scratch = scratch_root().join(format!( @@ -1679,8 +1805,8 @@ fn test_trace(name: &'static str, handoffs: Vec) -> SourceTrace { }; let state = sched::ThreadState { policy: 2, - base_pri: 97, - cur_pri: 97, + base_pri: sched::REALTIME_PRIORITY, + cur_pri: sched::REALTIME_PRIORITY, max_pri: 63, cpu_us: 0, }; @@ -1728,7 +1854,7 @@ fn simulated_handoffs( built, pushed, admission: admission(slot), - pri: 97, + pri: sched::REALTIME_PRIORITY, } }) .collect() @@ -1810,12 +1936,16 @@ fn source_lateness_is_attributed_to_the_source_or_to_cap() { ); // A source below the real-time band is a harness failure. let mut demoted = overslept.clone(); - demoted.handoffs[100].pri = 31; + demoted.handoffs[100].pri = sched::REALTIME_PRIORITY - 1; assert!(source_scheduling(&demoted, reference, window).is_err()); assert!(source_scheduling(&overslept, reference, window).is_ok()); } #[test] +#[cfg_attr( + not(target_os = "macos"), + ignore = "its expected class is a macOS expectation (recording/instant/screen-mic-system sync-screen-mic)" +)] fn sentinel_freezes_excuse_only_failures_localized_inside_them() { let reference = host_now(); // A sentinel log with a 300 ms gap at 9.9 s and a 60 ms gap at 5 s. @@ -1972,17 +2102,24 @@ fn scratch_root() -> PathBuf { async fn record( spec: &Spec, capture: CaptureMode, - _preflight: &SendableShareableContent, + _preflight: &Shareable, scratch: &Path, reference: u64, case: &mut Case, ) -> anyhow::Result { + let system_output = platform::system_audio_output(); + if spec.system_audio && require_system_audio() && !system_output { + case.problem( + "missing", + "system audio required but this host has no audio output device", + ); + } let stimulus_spec = StimulusSpec { fps: spec.stimulus_fps, seconds: spec.seconds, holds: spec.holds.clone(), busy: true, - audio: spec.system_audio && stimulus_plays_system_audio(), + audio: spec.system_audio && stimulus_plays_system_audio() && system_output, warmup: match capture { CaptureMode::Display => 2.0, CaptureMode::Window => 2.0, @@ -2025,6 +2162,10 @@ async fn record( let project = scratch.join("recording.cap"); let defaults = RecordingDefaults::default(); + let system_audio = spec.system_audio && platform::system_audio_output(); + if spec.system_audio && !system_audio { + case.note("no audio output device: recorded without system audio"); + } let gate = spec.gated.then(cap_recording::RecordingStartGate::new); let (handle, max_fps) = match spec.mode { Mode::Studio { fragmented, .. } => { @@ -2034,7 +2175,7 @@ async fn record( excluded, fragmented, camera, - spec.system_audio, + system_audio, ); let mut builder = builder; if spec.mic { @@ -2047,15 +2188,16 @@ async fn record( builder = builder.with_camera_feed(lock.clone()); } ( - Handle::Studio(builder.build(Some(shareable.clone())).await?), + Handle::Studio(build_studio(builder, &shareable).await?), max_fps, ) } Mode::Instant => { let builder = instant_recording::Actor::builder(project.clone(), target) - .with_system_audio(spec.system_audio) - .with_max_output_size(defaults.instant_mode_max_resolution) - .with_excluded_windows(excluded); + .with_system_audio(system_audio) + .with_max_output_size(defaults.instant_mode_max_resolution); + #[cfg(target_os = "macos")] + let builder = builder.with_excluded_windows(excluded); let builder = if spec.mic { builder.with_mic_feed(mic_lock.clone()) } else { @@ -2066,22 +2208,16 @@ async fn record( None => builder, }; ( - Handle::Instant(builder.build(Some(shareable.clone())).await?), + Handle::Instant(build_instant(builder, &shareable).await?), cap_recording::DEFAULT_INSTANT_MODE_FPS, ) } Mode::StudioCrashed => { - let (builder, max_fps) = studio_builder( - project.clone(), - target, - excluded, - true, - false, - spec.system_audio, - ); + let (builder, max_fps) = + studio_builder(project.clone(), target, excluded, true, false, system_audio); let builder = builder.with_mic_feed(mic_lock.clone()); ( - Handle::Studio(builder.build(Some(shareable.clone())).await?), + Handle::Studio(build_studio(builder, &shareable).await?), max_fps, ) } @@ -2106,12 +2242,8 @@ async fn record( let crashed = if spec.mode == Mode::StudioCrashed { let snapshot = scratch.join("crash-snapshot.cap"); let snapshot_started = host_now(); - let status = std::process::Command::new("cp") - .arg("-cR") - .arg(&project) - .arg(&snapshot) - .status()?; - anyhow::ensure!(status.success(), "snapshot of the live recording failed"); + copy_project(&project, &snapshot) + .map_err(|e| anyhow::anyhow!("snapshot of the live recording failed: {e:#}"))?; case.note(format!( "crash snapshot at {:.3}s, cloned in {:.3}s", host_secs(snapshot_started, reference), @@ -2177,15 +2309,9 @@ async fn record( let (project_path, loss_budget) = match crashed { Some(snapshot) => { let recovery_copy = scratch.join("crashed.cap"); - let status = std::process::Command::new("cp") - .arg("-cR") - .arg(&snapshot) - .arg(&recovery_copy) - .status()?; - anyhow::ensure!( - status.success(), - "copy of the preserved crash snapshot failed" - ); + copy_project(&snapshot, &recovery_copy).map_err(|e| { + anyhow::anyhow!("copy of the preserved crash snapshot failed: {e:#}") + })?; let started = std::time::Instant::now(); let recovered = tokio::task::spawn_blocking(move || { let incomplete = RecoveryManager::inspect_recording(&recovery_copy) @@ -2223,6 +2349,63 @@ async fn record( /// pipeline still in flight. const CRASH_LOSS_BUDGET: f64 = 3.0; +/// Copies a project directory: APFS clones on macOS; a file-by-file copy on +/// Windows, where a live recorder's files are open for shared reading. Taken +/// of a live project, the copy is what a crash leaves on disk. +fn copy_project(from: &Path, to: &Path) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("cp") + .arg("-cR") + .arg(from) + .arg(to) + .status()?; + anyhow::ensure!(status.success(), "cp -cR failed"); + Ok(()) + } + #[cfg(windows)] + { + fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(to)?; + let mut copied = std::collections::HashSet::new(); + // The recorder renames `.tmp` fragments as it finishes them: a + // file listed can be gone by the time it is opened, under a name + // the next listing has. + for _ in 0..5 { + let mut vanished = false; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let name = entry.file_name(); + if copied.contains(&name) { + continue; + } + let target = to.join(&name); + let result = if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &target) + } else { + std::fs::copy(entry.path(), &target).map(|_| ()) + }; + match result { + Ok(()) => { + copied.insert(name); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => vanished = true, + Err(e) => return Err(e), + } + } + if !vanished { + return Ok(()); + } + } + Err(std::io::Error::other(format!( + "files in {} kept disappearing while it was copied", + from.display() + ))) + } + copy_dir(from, to).map_err(Into::into) + } +} + async fn finalize_instant( recording: &mut instant_recording::CompletedRecording, ) -> anyhow::Result<()> {