// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Drawing.Imaging; using System.IO; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; namespace Microsoft.PowerToys.UITest { /// /// Provides methods for recording the screen during UI tests. /// Requires FFmpeg to be installed and available in PATH. /// internal class ScreenRecording : IDisposable { private readonly string outputDirectory; private readonly string framesDirectory; private readonly string outputFilePath; private readonly List capturedFrames; private readonly SemaphoreSlim recordingLock = new(1, 1); private readonly Stopwatch recordingStopwatch = new(); private readonly string? ffmpegPath; private CancellationTokenSource? recordingCancellation; private Task? recordingTask; private bool isRecording; private int frameCount; [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd); [DllImport("gdi32.dll")] private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); [DllImport("user32.dll")] private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); [DllImport("user32.dll")] private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci); [DllImport("user32.dll")] private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); private const int CURSORSHOWING = 0x00000001; private const int DESKTOPHORZRES = 118; private const int DESKTOPVERTRES = 117; private const int DINORMAL = 0x0003; private const int TargetFps = 15; // 15 FPS for good balance of quality and size /// /// Initializes a new instance of the class. /// /// Directory where the recording will be saved. public ScreenRecording(string outputDirectory) { this.outputDirectory = outputDirectory; string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}"); outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4"); capturedFrames = new List(); frameCount = 0; // Check if FFmpeg is available ffmpegPath = FindFfmpeg(); if (ffmpegPath == null) { Console.WriteLine("FFmpeg not found. Screen recording will be disabled."); Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html"); } } /// /// Gets a value indicating whether screen recording is available (FFmpeg found). /// public bool IsAvailable => ffmpegPath != null; /// /// Starts recording the screen. /// /// A task representing the asynchronous operation. public async Task StartRecordingAsync() { await recordingLock.WaitAsync(); try { if (isRecording || !IsAvailable) { return; } // Create frames directory Directory.CreateDirectory(framesDirectory); recordingCancellation = new CancellationTokenSource(); isRecording = true; recordingStopwatch.Start(); // Start the recording task recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token)); Console.WriteLine($"Started screen recording at {TargetFps} FPS"); } catch (Exception ex) { Console.WriteLine($"Failed to start recording: {ex.Message}"); isRecording = false; } finally { recordingLock.Release(); } } /// /// Stops recording and encodes video. /// /// A task representing the asynchronous operation. public async Task StopRecordingAsync() { await recordingLock.WaitAsync(); try { if (!isRecording || recordingCancellation == null) { return; } // Signal cancellation recordingCancellation.Cancel(); // Wait for recording task to complete if (recordingTask != null) { await recordingTask; } recordingStopwatch.Stop(); isRecording = false; double duration = recordingStopwatch.Elapsed.TotalSeconds; Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds"); // Encode to video await EncodeToVideoAsync(); } catch (Exception ex) { Console.WriteLine($"Error stopping recording: {ex.Message}"); } finally { Cleanup(); recordingLock.Release(); } } /// /// Records frames from the screen. /// private void RecordFrames(CancellationToken cancellationToken) { try { int frameInterval = 1000 / TargetFps; var frameTimer = Stopwatch.StartNew(); while (!cancellationToken.IsCancellationRequested) { var frameStart = frameTimer.ElapsedMilliseconds; try { CaptureFrame(); } catch (Exception ex) { Console.WriteLine($"Error capturing frame: {ex.Message}"); } // Sleep for remaining time to maintain target FPS var frameTime = frameTimer.ElapsedMilliseconds - frameStart; var sleepTime = Math.Max(0, frameInterval - (int)frameTime); if (sleepTime > 0) { Thread.Sleep(sleepTime); } } } catch (OperationCanceledException) { // Expected when stopping } catch (Exception ex) { Console.WriteLine($"Error during recording: {ex.Message}"); } } /// /// Captures a single frame. /// private void CaptureFrame() { IntPtr hdc = GetDC(IntPtr.Zero); int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); ReleaseDC(IntPtr.Zero, hdc); Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb)) { using (Graphics g = Graphics.FromImage(bitmap)) { g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); ScreenCapture.CURSORINFO cursorInfo; cursorInfo.CbSize = Marshal.SizeOf(); if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) { IntPtr hdcDest = g.GetHdc(); DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); g.ReleaseHdc(hdcDest); } } string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg"); bitmap.Save(framePath, ImageFormat.Jpeg); capturedFrames.Add(framePath); frameCount++; } } /// /// Encodes captured frames to video using ffmpeg. /// private async Task EncodeToVideoAsync() { if (capturedFrames.Count == 0) { Console.WriteLine("No frames captured"); return; } try { // Build ffmpeg command with proper non-interactive flags string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg"); // -y: overwrite without asking // -nostdin: disable interaction // -loglevel error: only show errors // -stats: show encoding progress string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\""; Console.WriteLine($"Encoding {capturedFrames.Count} frames to video..."); var startInfo = new ProcessStartInfo { FileName = ffmpegPath!, Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, // Important: redirect stdin to prevent hanging CreateNoWindow = true, }; using var process = Process.Start(startInfo); if (process != null) { // Close stdin immediately to ensure FFmpeg doesn't wait for input process.StandardInput.Close(); // Read output streams asynchronously to prevent deadlock var outputTask = process.StandardOutput.ReadToEndAsync(); var errorTask = process.StandardError.ReadToEndAsync(); // Wait for process to exit await process.WaitForExitAsync(); // Get the output string stdout = await outputTask; string stderr = await errorTask; if (process.ExitCode == 0 && File.Exists(outputFilePath)) { var fileInfo = new FileInfo(outputFilePath); Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)"); } else { Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}"); if (!string.IsNullOrWhiteSpace(stderr)) { Console.WriteLine($"FFmpeg error: {stderr}"); } } } } catch (Exception ex) { Console.WriteLine($"Error encoding video: {ex.Message}"); } } /// /// Finds ffmpeg executable. /// private static string? FindFfmpeg() { // Check if ffmpeg is in PATH var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); foreach (var dir in pathDirs) { var ffmpegPath = Path.Combine(dir, "ffmpeg.exe"); if (File.Exists(ffmpegPath)) { return ffmpegPath; } } // Check common installation locations var commonPaths = new[] { @"C:\.tools\ffmpeg\bin\ffmpeg.exe", @"C:\ffmpeg\bin\ffmpeg.exe", @"C:\Program Files\ffmpeg\bin\ffmpeg.exe", @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe", @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe", }; foreach (var path in commonPaths) { if (File.Exists(path)) { return path; } } return null; } /// /// Gets the path to the recorded video file. /// public string OutputFilePath => outputFilePath; /// /// Gets the directory containing recordings. /// public string OutputDirectory => outputDirectory; /// /// Cleans up resources. /// private void Cleanup() { recordingCancellation?.Dispose(); recordingCancellation = null; recordingTask = null; // Clean up frames directory if it exists try { if (Directory.Exists(framesDirectory)) { Directory.Delete(framesDirectory, true); } } catch (Exception ex) { Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}"); } } /// /// Disposes resources. /// public void Dispose() { if (isRecording) { StopRecordingAsync().GetAwaiter().GetResult(); } Cleanup(); recordingLock.Dispose(); GC.SuppressFinalize(this); } } }