diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index ae2ecce797..75e4ceb46b 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -6496,8 +6496,8 @@ pub fn prepare_for_screenshot_capture(cx: &mut App) -> bool { /// the Tauri app -- and open the screenshot editor on the new bundle, the /// `automationShouldOpenScreenshotEditor` default. A failure reveals the main /// window if the picker had hidden it, since nothing else will. -pub fn screenshot_finished(captured: Option, cx: &mut App) { - let Some(png) = captured else { +pub fn screenshot_finished(captured: Option, cx: &mut App) { + let Some(crate::screenshot::CapturedScreenshot { png, source }) = captured else { let hidden = std::mem::take(&mut cx.global_mut::().main_hidden_for_picker); if hidden && RecordingSession::global(cx).read(cx).phase == Phase::Idle { show_main_window(cx); @@ -6527,7 +6527,7 @@ pub fn screenshot_finished(captured: Option, cx: &mut App) { // recording hands off to the video editor; `screenshot_editor_closed` // brings the main window back. cx.global_mut::().main_hidden_for_picker = false; - open_screenshot_editor(png, cx); + open_screenshot_editor_with_source(png, Some(source), cx); return; } @@ -6549,6 +6549,14 @@ pub fn screenshot_finished(captured: Option, cx: &mut App) { /// Must be reached through `cx.defer` from anything inside an entity update: /// opening a window paints it synchronously and would double-lease the caller. pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { + open_screenshot_editor_with_source(path, None, cx); +} + +fn open_screenshot_editor_with_source( + path: PathBuf, + source: Option, + cx: &mut App, +) { #[cfg(target_os = "linux")] if defer_window_until_capture_safe(cx) { return; @@ -6575,16 +6583,61 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { .ok() .flatten(); cx.spawn(async move |cx| { - if let Some(native) = &native { + if (source.is_none() || cfg!(target_os = "macos")) + && let Some(native) = &native + { platform::show_native(native); } - cx.update(|cx| bring_screenshot_editor_forward(handle, cx)); + cx.update(|cx| { + bring_screenshot_editor_forward(handle, cx); + if source.is_some() { + screenshot_editor::load_screenshot_project(key, handle, source, cx); + } + }); }) .detach(); hide_main_window(cx); return; } + let Some(handle) = create_screenshot_editor_window(key.clone(), true, cx) else { + return; + }; + hide_main_window(cx); + cx.defer(move |cx| bring_screenshot_editor_forward(handle, cx)); + prewarm_shared_wgpu_device(cx); + screenshot_editor::load_screenshot_project(key, handle, source, cx); +} + +pub(crate) fn prepare_screenshot_editor_window( + path: PathBuf, + cx: &mut App, +) -> Option> { + #[cfg(target_os = "linux")] + if defer_window_until_capture_safe(cx) { + return None; + } + let bundle = screenshot_editor::resolve_bundle(&path)?; + create_screenshot_editor_window(editor_key(&bundle), false, cx) +} + +pub(crate) fn discard_prepared_screenshot_editor( + handle: WindowHandle, + cx: &mut App, +) { + cx.global_mut::() + .screenshot_editors + .retain(|(_, existing)| existing.window_id() != handle.window_id()); + handle + .update(cx, |_, window, _| window.remove_window()) + .ok(); +} + +fn create_screenshot_editor_window( + key: PathBuf, + visible: bool, + cx: &mut App, +) -> Option> { let (bounds, display_id) = opening_window_placement( size( px(screenshot_editor::SCREENSHOT_EDITOR_WIDTH), @@ -6603,8 +6656,8 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { traffic_light_position: None, }), kind: WindowKind::Normal, - focus: true, - show: true, + focus: visible, + show: visible, is_resizable: true, is_minimizable: true, window_min_size: Some(fitted_window_min_size( @@ -6626,7 +6679,7 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { Ok(handle) => handle, Err(error) => { tracing::error!("screenshot editor window failed to open: {error:#}"); - return; + return None; } }; @@ -6636,21 +6689,17 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { handle .update(cx, |_, window, _| { platform::kick_display_link(window); - tracing::info!( - number = platform::window_number(window), - path = %key.display(), - "screenshot editor window opened" - ); + if visible { + tracing::info!( + number = platform::window_number(window), + path = %key.display(), + "screenshot editor window opened" + ); + } }) .ok(); - hide_main_window(cx); - // A global shortcut fires while another app is frontmost, and a window - // opened by an inactive app lands behind that app's windows: the editor - // only showed up once Cap was clicked. - cx.defer(move |cx| bring_screenshot_editor_forward(handle, cx)); - prewarm_shared_wgpu_device(cx); - screenshot_editor::load_screenshot_project(key, handle, cx); + Some(handle) } fn bring_screenshot_editor_forward(handle: WindowHandle, cx: &mut App) { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index ea64033517..87e8461ffb 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -403,6 +403,15 @@ fn main() { single_instance::init_linux_reopen(cx); updates::schedule_startup_check(cx); upload::queue::init(cx); + if std::env::var("CAP_GPUI_SCREENSHOT_WARM_LAYERS").as_deref() == Ok("1") { + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(500)) + .await; + cx.update(|cx| screenshot_editor::prewarm_screenshot_layers(cx)); + }) + .detach(); + } // The app menu (and with it ⌘W/⌘M/⌘Q) and the status-bar item. Both // reach into the window registry, so they come after it -- and the menu diff --git a/apps/desktop-gpui/src/screenshot.rs b/apps/desktop-gpui/src/screenshot.rs index b01af48946..dafb65849d 100644 --- a/apps/desktop-gpui/src/screenshot.rs +++ b/apps/desktop-gpui/src/screenshot.rs @@ -18,7 +18,12 @@ use anyhow::{Context as _, anyhow}; use cap_recording::sources::screen_capture::ScreenCaptureTarget; use gpui::App; -use crate::app_windows; +use crate::{app_windows, screenshot_editor::LoadedSource}; + +pub struct CapturedScreenshot { + pub png: PathBuf, + pub source: LoadedSource, +} pub fn take_screenshot(target: ScreenCaptureTarget, cx: &mut App) { if matches!(target, ScreenCaptureTarget::CameraOnly) { @@ -28,8 +33,29 @@ pub fn take_screenshot(target: ScreenCaptureTarget, cx: &mut App) { return; } tracing::info!(kind = target.kind_str(), "taking a screenshot"); + if crate::store::GeneralSettings::load() + .post_screenshot_behaviour + .opens_editor() + { + app_windows::prewarm_shared_wgpu_device(cx); + } let settle = app_windows::prepare_for_screenshot_capture(cx); + // GPUI's X11 backend maps windows even when WindowOptions.show is false. + let prepare_window = cfg!(any(target_os = "windows", target_os = "macos")) + && std::env::var("CAP_GPUI_SCREENSHOT_PREPARE_WINDOW").as_deref() == Ok("1") + && crate::store::GeneralSettings::load() + .post_screenshot_behaviour + .opens_editor(); + let (prepared_tx, prepared_rx) = flume::bounded(1); + let prepared_tx = prepare_window.then_some(prepared_tx); + if std::env::var("CAP_GPUI_SCREENSHOT_WARM_LAYERS").as_deref() == Ok("1") + && crate::store::GeneralSettings::load() + .post_screenshot_behaviour + .opens_editor() + { + crate::screenshot_editor::prewarm_screenshot_layers(cx); + } let task = gpui_tokio::Tokio::spawn_result(cx, async move { if settle { @@ -38,35 +64,47 @@ pub fn take_screenshot(target: ScreenCaptureTarget, cx: &mut App) { // the compositor needs a beat to actually drop them from screen. tokio::time::sleep(Duration::from_millis(150)).await; } - capture_and_write(target).await + capture_and_write(target, prepared_tx).await }); cx.spawn(async move |cx| { - let captured = match task.await { - Ok(png) => { - tracing::info!(path = %png.display(), "screenshot captured"); - Some(png) + let prepared_window = if prepare_window { + match prepared_rx.recv_async().await { + Ok(path) => cx.update(|cx| app_windows::prepare_screenshot_editor_window(path, cx)), + Err(_) => None, } + } else { + None + }; + let captured = match task.await { + Ok((png, source)) => Some(CapturedScreenshot { png, source }), Err(error) => { tracing::error!("screenshot capture failed: {error:#}"); None } }; - cx.update(|cx| app_windows::screenshot_finished(captured, cx)); + cx.update(|cx| { + if let Some(handle) = prepared_window + && (captured.is_none() + || !crate::store::GeneralSettings::load() + .post_screenshot_behaviour + .opens_editor()) + { + app_windows::discard_prepared_screenshot_editor(handle, cx); + } + app_windows::screenshot_finished(captured, cx); + }); }) .detach(); } -/// Capture, then write the `.cap` bundle. Returns the PNG path, which is what -/// the Tauri command returns (`NewScreenshotAdded.path` is the PNG, not the -/// bundle). -async fn capture_and_write(target: ScreenCaptureTarget) -> anyhow::Result { - let image = cap_recording::screenshot::capture_screenshot(target.clone()) - .await - .context("capturing the screenshot")?; - - crate::app_sounds::AppSound::Notification.play(); - +fn prepare_capture_project( + target: &ScreenCaptureTarget, + exclusive: bool, +) -> anyhow::Result<( + cap_project::RecordingMeta, + cap_project::ProjectConfiguration, +)> { let base = crate::library::screenshots_dir(); std::fs::create_dir_all(&base) .with_context(|| format!("creating screenshots dir {}", base.display()))?; @@ -87,13 +125,17 @@ async fn capture_and_write(target: ScreenCaptureTarget) -> anyhow::Result anyhow::Result anyhow::Result>, +) -> anyhow::Result<(PathBuf, LoadedSource)> { + let prepared = if let Some(sender) = prepared_tx { + let project = prepare_capture_project(&target, true)?; + let _ = sender.send_async(project.0.project_path.clone()).await; + Some(project) + } else { + None + }; + let image = match cap_recording::screenshot::capture_screenshot(target.clone()).await { + Ok(image) => image, + Err(error) => { + if let Some((meta, _)) = &prepared { + discard_capture_project(&meta.project_path); + } + return Err(error.context("capturing the screenshot")); + } + }; + crate::app_sounds::AppSound::Notification.play(); + let experimental = prepared.is_some(); + let (meta, config) = match prepared { + Some(project) => project, + None => prepare_capture_project(&target, false)?, + }; + let png = meta.project_path.join("original.png"); + let width = image.width(); + let height = image.height(); + let rgba = match write_png(png.clone(), image).await { + Ok(rgba) => rgba, + Err(error) => { + if experimental { + discard_capture_project(&meta.project_path); + } + return Err(error); + } + }; + tracing::info!(path = %png.display(), "screenshot captured"); + let cap_project::RecordingMetaInner::Studio(studio_meta) = &meta.inner else { + return Err(anyhow!("screenshot metadata is not a studio project")); + }; + let source = LoadedSource { + rgba: std::sync::Arc::new(rgba), + width, + height, + pretty_name: meta.pretty_name.clone(), + config, + studio_meta: (**studio_meta).clone(), + meta, + }; + Ok((png, source)) } /// The Tauri encode settings (`recording.rs:2987-3016`): RGB stays RGB, a /// capture past 8MP trades compression for speed, adaptive filtering either /// way. Blocking work, so off the runtime workers. -async fn write_png(path: PathBuf, image: image::DynamicImage) -> anyhow::Result<()> { +async fn write_png(path: PathBuf, image: image::DynamicImage) -> anyhow::Result> { tokio::task::spawn_blocking(move || { let width = image.width(); let height = image.height(); @@ -146,6 +260,7 @@ async fn write_png(path: PathBuf, image: image::DynamicImage) -> anyhow::Result< } else { image::codecs::png::CompressionType::Default }; + let rgba = image.to_rgba8().into_raw(); let data = image.into_bytes(); let file = @@ -159,8 +274,88 @@ async fn write_png(path: PathBuf, image: image::DynamicImage) -> anyhow::Result< compression, image::codecs::png::FilterType::Adaptive, ) - .context("encoding the screenshot PNG") + .context("encoding the screenshot PNG")?; + Ok(rgba) }) .await .context("the PNG encode task died")? } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_preparation_cleanup_keeps_unrelated_files() { + let directory = tempfile::tempdir().unwrap(); + let bundle = directory.path().join("failed.cap"); + std::fs::create_dir(&bundle).unwrap(); + for name in [ + "original.png", + "recording-meta.json", + "project-config.json", + "keep.txt", + ] { + std::fs::write(bundle.join(name), b"test").unwrap(); + } + discard_capture_project(&bundle); + assert!(bundle.join("keep.txt").exists()); + for name in ["original.png", "recording-meta.json", "project-config.json"] { + assert!(!bundle.join(name).exists()); + } + std::fs::remove_file(bundle.join("keep.txt")).unwrap(); + discard_capture_project(&bundle); + assert!(!bundle.exists()); + } + + #[tokio::test] + async fn retained_capture_pixels_match_saved_rgb_and_alpha() { + let directory = tempfile::tempdir().unwrap(); + let images = [ + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(4, 2, |x, y| { + image::Rgb([x as u8 * 40, y as u8 * 80, 173]) + })), + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 2, |x, y| { + image::Rgba([x as u8 * 40, y as u8 * 80, 173, x as u8 * 70]) + })), + ]; + for (index, image) in images.into_iter().enumerate() { + let path = directory.path().join(format!("{index}.png")); + let expected = image.to_rgba8().into_raw(); + let retained = write_png(path.clone(), image).await.unwrap(); + let saved = image::open(path).unwrap().to_rgba8().into_raw(); + assert_eq!(retained, expected); + assert_eq!(saved, retained); + } + } + + #[tokio::test] + async fn oversized_capture_pixels_can_still_be_saved() { + let directory = tempfile::tempdir().unwrap(); + for (width, height) in [(16_385, 2), (4, 16_385)] { + let path = directory.path().join("original.png"); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + width, + height, + image::Rgba([13, 71, 129, 255]), + )); + let retained = write_png(path.clone(), image).await.unwrap(); + let saved = image::open(&path).unwrap().to_rgba8(); + assert_eq!(saved.dimensions(), (width, height)); + assert_eq!(saved.as_raw(), &retained); + assert!(matches!( + crate::screenshot_editor::load_source(directory.path()), + Err(message) if message == format!("Image dimensions exceed maximum: {width}x{height}") + )); + assert!(path.exists()); + } + } + + #[tokio::test] + async fn failed_capture_save_does_not_deliver_pixels() { + let directory = tempfile::tempdir().unwrap(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::new(4, 2)); + let result = write_png(directory.path().to_path_buf(), image).await; + assert!(result.is_err()); + } +} diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 7e83655f2a..2c234456af 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -57,7 +57,7 @@ pub const SCREENSHOT_EDITOR_MIN_WIDTH: f32 = 800.; pub const SCREENSHOT_EDITOR_MIN_HEIGHT: f32 = 600.; /// `MAX_DIMENSION` (`screenshot_editor.rs:38` over there). -const MAX_DIMENSION: u32 = 16_384; +pub(crate) const MAX_DIMENSION: u32 = 16_384; /// `saveConfig`'s debounce (`context.tsx:427-438`) -- 1000ms, not the video /// editor's 250. @@ -106,7 +106,7 @@ pub fn resolve_bundle(path: &Path) -> Option { /// Everything the window and the renderer need off disk. pub struct LoadedSource { - pub rgba: Vec, + pub rgba: Arc>, pub width: u32, pub height: u32, pub pretty_name: String, @@ -153,7 +153,7 @@ pub fn load_source(bundle: &Path) -> Result { let config = ProjectConfiguration::load(bundle).unwrap_or_default(); Ok(LoadedSource { - rgba: image.to_rgba8().into_raw(), + rgba: Arc::new(image.to_rgba8().into_raw()), width, height, pretty_name: meta.pretty_name.clone(), @@ -163,6 +163,20 @@ pub fn load_source(bundle: &Path) -> Result { }) } +fn load_source_for_editor( + bundle: &Path, + captured: Option, +) -> Result { + let source = captured.map_or_else(|| load_source(bundle), Ok)?; + if source.width > MAX_DIMENSION || source.height > MAX_DIMENSION { + return Err(format!( + "Image dimensions exceed maximum: {}x{}", + source.width, source.height + )); + } + Ok(source) +} + // --------------------------------------------------------------------------- // The still renderer // --------------------------------------------------------------------------- @@ -188,6 +202,69 @@ pub enum ExportRequest { }, } +static WARM_SCREENSHOT_LAYERS: tokio::sync::Mutex> = + tokio::sync::Mutex::const_new(None); + +fn warm_layers_enabled() -> bool { + std::env::var("CAP_GPUI_SCREENSHOT_WARM_LAYERS").as_deref() == Ok("1") +} + +async fn prepare_warm_layers(device: wgpu::Device, queue: wgpu::Queue, software: bool) { + let mut slot = WARM_SCREENSHOT_LAYERS.lock().await; + if slot.as_ref().is_some_and(|(cached, _)| cached == &device) { + return; + } + *slot = None; + let builder_device = device.clone(); + match tokio::task::spawn_blocking(move || { + RendererLayers::new_with_options(&builder_device, &queue, software) + }) + .await + { + Ok(layers) => { + *slot = Some((device, layers)); + } + Err(error) => tracing::warn!(%error, "screenshot renderer warmup failed"), + } +} + +pub(crate) fn prewarm_screenshot_layers(cx: &App) { + if !warm_layers_enabled() { + return; + } + gpui_tokio::Tokio::spawn(cx, async { + if let Some(shared) = crate::app_windows::shared_wgpu_device().await { + prepare_warm_layers(shared.device, shared.queue, shared.is_software_adapter).await; + } + }) + .detach(); +} + +async fn screenshot_layers(constants: &RenderVideoConstants) -> RendererLayers { + if warm_layers_enabled() { + prepare_warm_layers( + constants.device.clone(), + constants.queue.clone(), + constants.is_software_adapter, + ) + .await; + let mut slot = WARM_SCREENSHOT_LAYERS.lock().await; + if slot + .as_ref() + .is_some_and(|(device, _)| device == &constants.device) + && let Some((_, layers)) = slot.take() + { + return layers; + } + *slot = None; + } + RendererLayers::new_with_options( + &constants.device, + &constants.queue, + constants.is_software_adapter, + ) +} + /// The render loop, on the tokio runtime: build the GPU constants, then /// re-render the still whenever the config changes and serve export requests /// on the same device. Ends when the window goes away (both senders drop). @@ -224,14 +301,10 @@ pub async fn run_still_renderer( }; let _ = setup_tx.send(Ok(())); - let decoded = DecodedFrame::new_with_arc(Arc::new(source.rgba), source.width, source.height); + let decoded = DecodedFrame::new_with_arc(source.rgba, source.width, source.height); let mut frame_renderer = FrameRenderer::new(&constants); - let mut layers = RendererLayers::new_with_options( - &constants.device, - &constants.queue, - constants.is_software_adapter, - ); + let mut layers = screenshot_layers(&constants).await; let mut dirty = true; loop { @@ -280,6 +353,17 @@ pub async fn run_still_renderer( } } } + if warm_layers_enabled() { + drop(layers); + drop(frame_renderer); + drop(decoded); + prepare_warm_layers( + constants.device.clone(), + constants.queue.clone(), + constants.is_software_adapter, + ) + .await; + } } /// One `render_immediate` of the still -- the body of the Tauri loop @@ -4760,13 +4844,15 @@ fn frame_buffers(frame: &RenderedFrame) -> Option<(Arc>, Arc, + captured: Option, cx: &mut App, ) { cx.spawn(async move |cx| { + let fresh_capture = captured.is_some(); let load_bundle = bundle.clone(); let source = cx .background_executor() - .spawn(async move { load_source(&load_bundle) }) + .spawn(async move { load_source_for_editor(&load_bundle, captured) }) .await; let source = match source { Ok(source) => source, @@ -4789,6 +4875,30 @@ pub fn load_screenshot_project( let (setup_tx, setup_rx) = flume::bounded(1); let image_size = (source.width, source.height); + let (source, preview) = cx + .background_executor() + .spawn(async move { + let preview = if fresh_capture + && source.width.is_multiple_of(4) + && source.height.is_multiple_of(2) + { + image::RgbaImage::from_raw( + source.width, + source.height, + source.rgba.as_ref().clone(), + ) + .map(|image| { + ( + source.rgba.clone(), + crate::library::rgba_to_render_image(image), + ) + }) + } else { + None + }; + (source, preview) + }) + .await; if handle .update(cx, |view, window, cx| { view.set_loaded( @@ -4801,7 +4911,10 @@ pub fn load_screenshot_project( }, window, cx, - ) + ); + if let Some((rgba, image)) = preview { + view.frame_arrived(image, rgba, image_size, window, cx); + } }) .is_err() { @@ -4841,7 +4954,9 @@ pub fn load_screenshot_project( ); if handle .update(cx, |view, window, cx| { - view.frame_arrived(image, rgba, size, window, cx) + if revision >= view.revision { + view.frame_arrived(image, rgba, size, window, cx); + } }) .is_err() { @@ -5134,7 +5249,7 @@ mod tests { let mut config = new_screenshot_config(); config.annotations.push(arrow("a", 4.)); LoadedSource { - rgba, + rgba: Arc::new(rgba), width, height, pretty_name: "bench".into(), @@ -5144,6 +5259,26 @@ mod tests { } } + #[test] + fn retained_capture_pixels_obey_the_editor_dimension_limit() { + for (width, height) in [(MAX_DIMENSION + 1, 2), (4, MAX_DIMENSION + 1)] { + let result = load_source_for_editor( + Path::new("unused.cap"), + Some(synthetic_source(width, height)), + ); + assert!( + matches!(result, Err(message) if message == format!("Image dimensions exceed maximum: {width}x{height}")) + ); + } + assert!( + load_source_for_editor( + Path::new("unused.cap"), + Some(synthetic_source(MAX_DIMENSION, 2)), + ) + .is_ok() + ); + } + struct DragResult { renders: usize, cpu_ms: f64, diff --git a/scripts/benchmark-screenshot-startup.ps1 b/scripts/benchmark-screenshot-startup.ps1 new file mode 100644 index 0000000000..b8cf7acef6 --- /dev/null +++ b/scripts/benchmark-screenshot-startup.ps1 @@ -0,0 +1,71 @@ +param( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$OutputDirectory, + [ValidateRange(1, 20)][int]$Samples = 5, + [ValidateRange(10, 120)][int]$TimeoutSeconds = 45 +) + +$ErrorActionPreference = "Stop" +$executablePath = (Resolve-Path -LiteralPath $Executable).Path +$outputPath = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $outputPath -Force | Out-Null +$environmentNames = @( + "CAP_GPUI_APP_DATA_DIR", "CAP_GPUI_TAURI_STORE", "CAP_GPUI_AUTO_HOTKEY", + "CAP_GPUI_SCREENSHOT_PREPARE_WINDOW", "CAP_GPUI_SCREENSHOT_WARM_LAYERS", "RUST_LOG" +) +$originalEnvironment = @{} +foreach ($name in $environmentNames) { + $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +$results = @() +Write-Host "Measures capture request to window opened using existing logs; does not measure rendered content." +try { + for ($sample = 1; $sample -le $Samples; $sample++) { + $runPath = Join-Path $outputPath ("run-{0}-{1}" -f $sample, [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $runPath | Out-Null + $env:CAP_GPUI_APP_DATA_DIR = Join-Path $runPath "profile" + $env:CAP_GPUI_TAURI_STORE = Join-Path $runPath "store.json" + $env:CAP_GPUI_AUTO_HOTKEY = "screenshotDisplay" + $env:CAP_GPUI_SCREENSHOT_PREPARE_WINDOW = "0" + $env:CAP_GPUI_SCREENSHOT_WARM_LAYERS = "0" + $env:RUST_LOG = "warn,cap_gpui=info" + $stdout = Join-Path $runPath "stdout.log" + $stderr = Join-Path $runPath "stderr.log" + $child = $null + try { + $child = Start-Process -FilePath $executablePath -WorkingDirectory (Split-Path $executablePath) -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $opened = $null + while ([DateTime]::UtcNow -lt $deadline) { + $lines = @(Get-Content -LiteralPath $stdout -ErrorAction SilentlyContinue | ForEach-Object { + $_ -replace '\x1B\[[0-?]*[ -/]*[@-~]', "" + }) + $capture = $lines | Where-Object { $_ -match "taking a screenshot" } | Select-Object -First 1 + $opened = $lines | Where-Object { $_ -match "screenshot editor window opened" } | Select-Object -First 1 + if ($capture -and $opened) { break } + if ($child.HasExited) { throw "Benchmark process exited; inspect $stderr" } + Start-Sleep -Milliseconds 100 + } + if (-not $capture -or -not $opened) { throw "Timed out; inspect $runPath" } + $captureTime = [DateTimeOffset]::Parse(($capture -split "\s+")[0], [Globalization.CultureInfo]::InvariantCulture) + $openedTime = [DateTimeOffset]::Parse(($opened -split "\s+")[0], [Globalization.CultureInfo]::InvariantCulture) + $results += [pscustomobject]@{ + Sample = $sample + CaptureRequestToWindowOpenedMs = ($openedTime - $captureTime).TotalMilliseconds + LogDirectory = $runPath + } + $results | Export-Csv -LiteralPath (Join-Path $outputPath "results.csv") -NoTypeInformation + } finally { + if ($child) { + if (-not $child.HasExited) { Stop-Process -Id $child.Id -Force } + $child.WaitForExit() + $child.Dispose() + } + } + } +} finally { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name], "Process") + } +} +$results