Start-ScreenRecorder.ps1

<#PSScriptInfo
.VERSION 1.2.0
.GUID d47eab76-de84-454d-aead-8b61ed3335eb
.AUTHOR Yoshifumi Tsuda
.COPYRIGHT Copyright (c) 2025-2026 Yoshifumi Tsuda. MIT License.
.TAGS Screen Capture Recording Debug Screenshot Clock
.LICENSEURI https://github.com/yotsuda/ScreenRecorder/blob/master/LICENSE
.PROJECTURI https://github.com/yotsuda/ScreenRecorder
.DESCRIPTION Screen capture tool with clock overlay for debugging and log correlation.
#>


# File-level param: receives args when this .ps1 is invoked directly
# (including via Start-Process for the -Background relaunch).
# KEEP IN SYNC with the function-level param block below — they must match
# so that `Start-ScreenRecorder @PSBoundParameters` forwards every option.
param(
    [Parameter(DontShow)]
    [switch]$Background,
    [ValidateRange(1, 60)]
    [int]$FPS = 2,
    [ValidateRange(0.1, 1.0)]
    [double]$Scale = 0.75,
    [ValidateRange(1, 100)]
    [int]$Quality = 75,
    [switch]$SaveMasked,
    [Parameter(DontShow)]
    [string]$ReadyFile,
    [ArgumentCompleter({ '00:05:00' })]
    [TimeSpan]$RecordFor,
    [string]$OutputPath
)

function Start-ScreenRecorder {
    # Help content lives in docs/en-US/Start-ScreenRecorder.md (PlatyPS source)
    # and is compiled to en-US/ScreenRecorder-help.xml via New-ExternalHelp.
    # The .EXTERNALHELP directive below points runtime Get-Help at that XML.
    [CmdletBinding(HelpUri = 'https://github.com/yotsuda/ScreenRecorder/blob/master/docs/en-US/Start-ScreenRecorder.md')]
    # KEEP IN SYNC with the file-level param block at the top of this file.
    param(
        [Parameter(DontShow)]
        [switch]$Background,
        [ValidateRange(1, 60)]
        [int]$FPS = 2,
        [ValidateRange(0.1, 1.0)]
        [double]$Scale = 0.75,
        [ValidateRange(1, 100)]
        [int]$Quality = 75,
        [switch]$SaveMasked,
        [Parameter(DontShow)]
        [string]$ReadyFile,
        [ArgumentCompleter({ '00:05:00' })]
        [TimeSpan]$RecordFor,
        [string]$OutputPath
    )
    # .EXTERNALHELP ScreenRecorder-help.xml

    if (-not $Background) {
        Write-Host 'Starting ScreenRecorder... ' -NoNewline
        $fgReadyFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "sr_ready_$PID.tmp")
        $exe = (Get-Process -Id $PID).Path
        $scriptPath = $MyInvocation.MyCommand.ScriptBlock.File
        if (-not $scriptPath) { $scriptPath = $PSCommandPath }
        $procArgs = "-NoProfile -WindowStyle Hidden -File `"$scriptPath`" -Background -FPS $FPS -Scale $Scale -Quality $Quality -ReadyFile `"$fgReadyFile`""
        if ($SaveMasked) { $procArgs += " -SaveMasked" }
        if ($RecordFor -gt [TimeSpan]::Zero) { $procArgs += " -RecordFor $($RecordFor.ToString())" }
        if ($OutputPath) { $procArgs += " -OutputPath `"$OutputPath`"" }
        Start-Process $exe -ArgumentList $procArgs -WindowStyle Hidden
        # Wait for background to be ready with spinner
        $spinner = '|', '/', '-', '\'
        $i = 0
        $timeout = [DateTime]::Now.AddSeconds(30)
        [Console]::CursorVisible = $false
        while (-not (Test-Path $fgReadyFile) -and [DateTime]::Now -lt $timeout) {
            Write-Host "`b$($spinner[$i++ % 4])" -NoNewline
            Start-Sleep -Milliseconds 100
        }
        [Console]::CursorVisible = $true
        $ready = Test-Path $fgReadyFile
        Remove-Item $fgReadyFile -ErrorAction SilentlyContinue
        if ($ready) {
            Write-Host "`b Ready!"
        } else {
            Write-Host "`b Failed (background process did not signal ready within 30s)" -ForegroundColor Red
        }
        return
    }
    Add-Type -AssemblyName PresentationFramework,System.Windows.Forms,System.Drawing
    # Add-Type registers types globally per session, so re-running Start-ScreenRecorder
    # in the same shell would throw "type already exists" without this guard.
    if (-not ('DisplayHelper' -as [type])) {
    $drawingAsm = [System.Drawing.Bitmap].Assembly.Location
    # System.Drawing.Primitives DLL — needed as -ReferencedAssemblies on PS7 where
    # primitive types (Rectangle/Point) live in a separate assembly from System.Drawing.Common.
    $primAsm = [System.Drawing.Rectangle].Assembly.Location
    $winCoreAsm = [System.Drawing.Bitmap].Assembly.GetReferencedAssemblies() |
        Where-Object { $_.Name -eq 'System.Private.Windows.Core' } |
        ForEach-Object { [System.Reflection.Assembly]::Load($_).Location }
    Add-Type -TypeDefinition @"
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
 
public class DisplayHelper {
    [DllImport("user32.dll")]
    public static extern bool EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode);
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    public struct DEVMODE {
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
        public string dmDeviceName;
        public short dmSpecVersion, dmDriverVersion, dmSize, dmDriverExtra;
        public int dmFields, dmPositionX, dmPositionY, dmDisplayOrientation, dmDisplayFixedOutput;
        public short dmColor, dmDuplex, dmYResolution, dmTTOption, dmCollate;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
        public string dmFormName;
        public short dmLogPixels;
        public int dmBitsPerPel, dmPelsWidth, dmPelsHeight, dmDisplayFlags, dmDisplayFrequency;
    }
    public const int ENUM_CURRENT_SETTINGS = -1;
 
    [StructLayout(LayoutKind.Sequential)]
    public struct RECT { public int Left, Top, Right, Bottom; }
 
    [DllImport("dwmapi.dll")]
    public static extern int DwmGetWindowAttribute(IntPtr hWnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
 
    public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9;
 
    // Get physical window rect using DWM (always returns physical pixels)
    public static RECT GetPhysicalWindowRect(IntPtr hWnd) {
        RECT rect;
        DwmGetWindowAttribute(hWnd, DWMWA_EXTENDED_FRAME_BOUNDS, out rect, Marshal.SizeOf(typeof(RECT)));
        return rect;
    }
 
    // Fast FNV-1a hash for bitmap comparison (4x4 sampling for performance)
    public static long ComputeImageHash(Bitmap bmp, int exL, int exT, int exR, int exB) {
        var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
            ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
        try {
            long hash = unchecked((long)0xcbf29ce484222325);
            int stride = data.Stride;
            int width = bmp.Width;
            int height = bmp.Height;
            IntPtr scan0 = data.Scan0;
            for (int y = 0; y < height; y += 4) {
                IntPtr row = IntPtr.Add(scan0, y * stride);
                if (y >= exT && y < exB) {
                    for (int x = 0; x < exL; x += 4) { hash ^= Marshal.ReadInt32(row, x * 4); hash *= 0x100000001b3L; }
                    for (int x = exR; x < width; x += 4) { hash ^= Marshal.ReadInt32(row, x * 4); hash *= 0x100000001b3L; }
                } else {
                    for (int x = 0; x < width; x += 4) { hash ^= Marshal.ReadInt32(row, x * 4); hash *= 0x100000001b3L; }
                }
            }
            return hash;
        } finally {
            bmp.UnlockBits(data);
        }
    }
}
 
public class BackgroundRecorder {
    private Task _task;
    private CancellationTokenSource _cts;
    private Action<string> _errorCallback;
    private int _intervalMs;
    private string _outDir;
    private bool _saveMasked;
    private double _scale;
    private IntPtr _windowHandle;
    private ImageCodecInfo _jpegCodec;
    private EncoderParameters _encoderParams;
    private Bitmap _captureBmp, _thumbBmp;
    private Graphics _captureG, _thumbG;
    private Rectangle _bounds;
    private int _thumbW, _thumbH;
    private long _prevHash;
    private int _saved;
    private string _lastError;
    private int _quality;
    private volatile bool _showOverlay;
    // Multi-monitor support
    private Rectangle[] _monitorBounds;
    private Bitmap[] _monitorBmps;
    private Graphics[] _monitorGs;
 
    public int Saved { get { return _saved; } }
    public string LastError { get { return _lastError; } }
    public void MarkSettingsChanged(int quality) {
        _quality = quality;
        _encoderParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)quality);
        _showOverlay = true;
    }
 
    public bool Start(Rectangle bounds, Rectangle[] monitorBounds, int thumbW, int thumbH, int fps, int quality, string outDir, bool saveMasked, double scale, IntPtr windowHandle, Action<string> errorCallback) {
        // Validate parameters
        if (bounds.Width <= 0 || bounds.Height <= 0 || thumbW <= 0 || thumbH <= 0) {
            _lastError = "Invalid dimensions: bounds=" + bounds.Width + "x" + bounds.Height + ", thumb=" + thumbW + "x" + thumbH;
            return false;
        }
 
        // Cleanup any previous resources
        Stop();
 
        _bounds = bounds;
        _monitorBounds = monitorBounds;
        _thumbW = thumbW;
        _thumbH = thumbH;
        _intervalMs = 1000 / fps;
        _outDir = outDir;
        _saveMasked = saveMasked;
        _scale = scale;
        _windowHandle = windowHandle;
        _prevHash = 0;
        _saved = 0;
        _quality = quality;
        _errorCallback = errorCallback;
 
        try {
            _captureBmp = new Bitmap(bounds.Width, bounds.Height);
            try {
                _captureG = Graphics.FromImage(_captureBmp);
                _thumbBmp = new Bitmap(thumbW, thumbH);
                try {
                    _thumbG = Graphics.FromImage(_thumbBmp);
 
                    // Allocate per-monitor bitmaps for multi-monitor capture
                    if (_monitorBounds != null && _monitorBounds.Length > 1) {
                        _monitorBmps = new Bitmap[_monitorBounds.Length];
                        _monitorGs = new Graphics[_monitorBounds.Length];
                        try {
                            for (int i = 0; i < _monitorBounds.Length; i++) {
                                _monitorBmps[i] = new Bitmap(_monitorBounds[i].Width, _monitorBounds[i].Height);
                                _monitorGs[i] = Graphics.FromImage(_monitorBmps[i]);
                            }
                        } catch {
                            // Clean up partial allocations
                            for (int i = 0; i < _monitorBmps.Length; i++) {
                                if (_monitorGs != null && i < _monitorGs.Length && _monitorGs[i] != null) {
                                    _monitorGs[i].Dispose();
                                    _monitorGs[i] = null;
                                }
                                if (_monitorBmps[i] != null) {
                                    _monitorBmps[i].Dispose();
                                    _monitorBmps[i] = null;
                                }
                            }
                            throw;
                        }
                    }
                } catch {
                    if (_thumbBmp != null) { _thumbBmp.Dispose(); _thumbBmp = null; }
                    throw;
                }
            } catch {
                if (_captureG != null) { _captureG.Dispose(); _captureG = null; }
                if (_captureBmp != null) { _captureBmp.Dispose(); _captureBmp = null; }
                throw;
            }
        } catch (Exception ex) {
            _lastError = "Bitmap init failed: " + ex.Message;
            return false;
        }
 
        _jpegCodec = null;
        foreach (var codec in ImageCodecInfo.GetImageEncoders()) {
            if (codec.MimeType == "image/jpeg") { _jpegCodec = codec; break; }
        }
        _encoderParams = new EncoderParameters(1);
        _encoderParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)quality);
 
        _cts = new CancellationTokenSource();
        _task = Task.Run(() => RecordLoop(_cts.Token));
        return true;
    }
 
    public void Stop() {
        // Signal cancellation and wait for task to complete
        if (_cts != null) {
            _cts.Cancel();
            if (_task != null) {
                try {
                    if (!_task.Wait(5000)) {
                        // Task didn't complete in 5 seconds - force cleanup
                        _lastError = "Task timeout during stop - forced cleanup";
                    }
                } catch (AggregateException) {
                    // Task was cancelled - this is expected
                }
                _task = null;
            }
            _cts.Dispose();
            _cts = null;
        }
        // Dispose per-monitor resources
        if (_monitorGs != null) {
            for (int i = 0; i < _monitorGs.Length; i++) {
                if (_monitorGs[i] != null) { _monitorGs[i].Dispose(); _monitorGs[i] = null; }
            }
            _monitorGs = null;
        }
        if (_monitorBmps != null) {
            for (int i = 0; i < _monitorBmps.Length; i++) {
                if (_monitorBmps[i] != null) { _monitorBmps[i].Dispose(); _monitorBmps[i] = null; }
            }
            _monitorBmps = null;
        }
        if (_thumbG != null) { _thumbG.Dispose(); _thumbG = null; }
        if (_thumbBmp != null) { _thumbBmp.Dispose(); _thumbBmp = null; }
        if (_captureG != null) { _captureG.Dispose(); _captureG = null; }
        if (_captureBmp != null) { _captureBmp.Dispose(); _captureBmp = null; }
        if (_encoderParams != null) { _encoderParams.Dispose(); _encoderParams = null; }
    }
 
    private void RecordLoop(CancellationToken ct) {
        DisplayHelper.RECT _prevRect = new DisplayHelper.RECT();
        bool firstFrame = true;
 
        while (!ct.IsCancellationRequested) {
            var sw = Stopwatch.StartNew();
            try {
                // Get exclude rect BEFORE capture (more accurate timing)
                var rect = DisplayHelper.GetPhysicalWindowRect(_windowHandle);
                int exL = (int)((rect.Left - _bounds.Left) * _scale) / 4 * 4;
                int exT = (int)((rect.Top - _bounds.Top) * _scale) / 4 * 4;
                int exR = ((int)((rect.Right - _bounds.Left) * _scale) + 3) / 4 * 4;
                int exB = ((int)((rect.Bottom - _bounds.Top) * _scale) + 3) / 4 * 4;
 
                // Skip if window is moving (rect changed since last frame)
                bool isMoving = !firstFrame && (rect.Left != _prevRect.Left || rect.Top != _prevRect.Top ||
                                                 rect.Right != _prevRect.Right || rect.Bottom != _prevRect.Bottom);
                _prevRect = rect;
                firstFrame = false;
 
                if (isMoving) {
                    // Skip this frame - window is moving
                    var skipElapsed = (int)sw.ElapsedMilliseconds;
                    int skipSleep = _intervalMs - skipElapsed;
                    if (skipSleep > 0) {
                        try { Task.Delay(skipSleep, ct).Wait(); }
                        catch (AggregateException) { return; }
                    }
                    continue;
                }
 
                // Capture screen
                if (_monitorBounds == null || _monitorBounds.Length == 1) {
                    _captureG.CopyFromScreen(_bounds.Location, Point.Empty, _bounds.Size);
                } else {
                    _captureG.Clear(Color.Black);
                    for (int i = 0; i < _monitorBounds.Length; i++) {
                        var b = _monitorBounds[i];
                        _monitorGs[i].CopyFromScreen(b.Location, Point.Empty, b.Size);
                        int relX = b.Left - _bounds.Left;
                        int relY = b.Top - _bounds.Top;
                        _captureG.DrawImage(_monitorBmps[i], relX, relY);
                    }
                }
 
                _thumbG.DrawImage(_captureBmp, 0, 0, _thumbW, _thumbH);
 
                long hash = DisplayHelper.ComputeImageHash(_thumbBmp, exL, exT, exR, exB);
                if (hash != _prevHash || _showOverlay) {
                    string filename = DateTime.Now.ToString("yyyyMMdd_HHmmss_ff");
                    if (_saved == 0 || _showOverlay) {
                        _showOverlay = false;
                        using (var path = new System.Drawing.Drawing2D.GraphicsPath())
                        using (var fontFamily = new FontFamily("Consolas"))
                        {
                            _thumbG.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
                            int fontSize = Math.Max(16, _thumbH / 25);
                            float lineH = fontSize * 1.3f;
                            string qname = _quality == 25 ? "Low" : _quality == 50 ? "Medium" : _quality == 75 ? "High" : _quality == 100 ? "Best" : _quality.ToString();
                            path.AddString("Quality: " + qname, fontFamily, (int)FontStyle.Regular, fontSize, new PointF(0, 0), StringFormat.GenericDefault);
                            path.AddString("Scale: " + (int)(_scale * 100) + "%", fontFamily, (int)FontStyle.Regular, fontSize, new PointF(0, lineH), StringFormat.GenericDefault);
                            var bounds = path.GetBounds();
                            int pad = fontSize * 2 / 3;
                            // Find corner farthest from clock window
                            float clockCX = (exL + exR) / 2f, clockCY = (exT + exB) / 2f;
                            float[][] corners = { new[]{0f,0f}, new[]{1f,0f}, new[]{0f,1f}, new[]{1f,1f} };
                            int best = 0; float maxDist = 0;
                            for (int i = 0; i < 4; i++) {
                                float cx = corners[i][0] * _thumbW, cy = corners[i][1] * _thumbH;
                                float dx = cx - clockCX, dy = cy - clockCY;
                                if (dx*dx + dy*dy > maxDist) { maxDist = dx*dx + dy*dy; best = i; }
                            }
                            float x = (corners[best][0] == 0) ? pad : _thumbW - bounds.Width - pad * 3;
                            float y = (corners[best][1] == 0) ? pad : _thumbH - bounds.Height - pad * 3;
                            var matrix = new System.Drawing.Drawing2D.Matrix();
                            matrix.Translate(x - bounds.X + pad, y - bounds.Y + pad);
                            path.Transform(matrix);
                            var finalBounds = path.GetBounds();
                            using (var bgBrush = new SolidBrush(Color.FromArgb(180, 0, 0, 0)))
                            using (var bgPath = new System.Drawing.Drawing2D.GraphicsPath()) {
                                float bx = finalBounds.X - pad, by = finalBounds.Y - pad, bw = finalBounds.Width + pad * 2, bh = finalBounds.Height + pad * 2, r = fontSize / 2f;
                                bgPath.AddArc(bx, by, r * 2, r * 2, 180, 90); bgPath.AddArc(bx + bw - r * 2, by, r * 2, r * 2, 270, 90);
                                bgPath.AddArc(bx + bw - r * 2, by + bh - r * 2, r * 2, r * 2, 0, 90); bgPath.AddArc(bx, by + bh - r * 2, r * 2, r * 2, 90, 90);
                                bgPath.CloseFigure(); _thumbG.FillPath(bgBrush, bgPath);
                            }
 
                            _thumbG.FillPath(Brushes.White, path);
                        }
                    }
                    _thumbBmp.Save(System.IO.Path.Combine(_outDir, filename + ".jpg"), _jpegCodec, _encoderParams);
                    if (_saveMasked) {
                        using (var maskedBmp = new Bitmap(_thumbBmp))
                        using (var g = Graphics.FromImage(maskedBmp)) {
                            g.FillRectangle(Brushes.Black, exL, exT, exR - exL, exB - exT);
                            maskedBmp.Save(System.IO.Path.Combine(_outDir, filename + "_masked.jpg"), _jpegCodec, _encoderParams);
                        }
                    }
                    _saved++;
                    _prevHash = hash;
                }
            } catch (Exception ex) {
                _lastError = ex.ToString();
                if (_errorCallback != null) _errorCallback(_lastError);
            }
 
            var elapsed = (int)sw.ElapsedMilliseconds;
            int sleep = _intervalMs - elapsed;
            if (sleep > 0) {
                try { Task.Delay(sleep, ct).Wait(); }
                catch (AggregateException) { return; }
            }
        }
    }
}
"@
 -ReferencedAssemblies (@($drawingAsm,$primAsm) + @($winCoreAsm | Where-Object { $_ }))
    }
    [xml]$xaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    Topmost="True" AllowsTransparency="True" WindowStyle="None" Background="#01000000"
    SizeToContent="WidthAndHeight" ResizeMode="NoResize" Left="20" Top="20">
    <Window.ContextMenu>
        <ContextMenu>
            <MenuItem Name="MenuInvisible" IsCheckable="True">
                <MenuItem.Header>
                    <TextBlock><Underline>A</Underline>uto-hide</TextBlock>
                </MenuItem.Header>
            </MenuItem>
            <MenuItem Name="MenuQuality" Header="Quality">
                <MenuItem Name="MenuQ25" Header="Low (25)" IsCheckable="True"/>
                <MenuItem Name="MenuQ50" Header="Medium (50)" IsCheckable="True"/>
                <MenuItem Name="MenuQ75" Header="High (75)" IsCheckable="True" IsChecked="True"/>
                <MenuItem Name="MenuQ100" Header="Best (100)" IsCheckable="True"/>
            </MenuItem>
            <MenuItem Name="MenuScale" Header="Scale">
                <MenuItem Name="MenuS50" Header="50%" IsCheckable="True"/>
                <MenuItem Name="MenuS75" Header="75%" IsCheckable="True" IsChecked="True"/>
                <MenuItem Name="MenuS100" Header="100%" IsCheckable="True"/>
            </MenuItem>
            <MenuItem Name="MenuFPS" Header="FPS">
                <MenuItem Name="MenuF1" Header="1" IsCheckable="True"/>
                <MenuItem Name="MenuF2" Header="2" IsCheckable="True" IsChecked="True"/>
                <MenuItem Name="MenuF5" Header="5" IsCheckable="True"/>
                <MenuItem Name="MenuF10" Header="10" IsCheckable="True"/>
            </MenuItem>
            <MenuItem Name="MenuExit">
                <MenuItem.Header>
                    <TextBlock>E<Underline>x</Underline>it</TextBlock>
                </MenuItem.Header>
            </MenuItem>
        </ContextMenu>
    </Window.ContextMenu>
    <Border Name="MainBorder" Background="#AA000000" CornerRadius="8" Padding="10,6">
        <StackPanel>
            <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
                <TextBlock Name="Clock" Foreground="White" FontSize="32" FontFamily="Consolas" VerticalAlignment="Center"/>
                <StackPanel Margin="8,0,0,0" VerticalAlignment="Center">
                    <TextBlock Name="RecSpacer" Foreground="#AAAAAA" FontSize="11" Height="14" TextAlignment="Right"/>
                    <Button Name="BtnToggle" Content="● REC" Width="45" Height="22" FontSize="11" Background="#AA444444" Foreground="White" BorderThickness="0" Padding="0"/>
                    <TextBlock Name="RecCounter" Text="0" Foreground="#FF6666" FontSize="11" Height="14" TextAlignment="Right" Visibility="Hidden"/>
                </StackPanel>
                <TextBlock Name="MonitorLabel" Foreground="White" FontSize="10" Margin="4,0,0,0" VerticalAlignment="Center" Cursor="Hand" Visibility="Collapsed"/>
            </StackPanel>
        </StackPanel>
    </Border>
</Window>
"@

    $window = [Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($xaml))
    $clock = $window.FindName("Clock")
    $btnToggle = $window.FindName("BtnToggle")
    $mainBorder = $window.FindName("MainBorder")
    $recCounter = $window.FindName("RecCounter")
    $recSpacer = $window.FindName("RecSpacer")
    $script:monitorLabel = $window.FindName("MonitorLabel")
    $menuExit = $window.FindName("MenuExit")
    $menuInvisible = $window.FindName("MenuInvisible")
    $script:quality = $Quality
    $script:qualityMenus = @{
        25 = $window.FindName("MenuQ25")
        50 = $window.FindName("MenuQ50")
        75 = $window.FindName("MenuQ75")
        100 = $window.FindName("MenuQ100")
    }
    foreach ($q in $script:qualityMenus.Keys) {
        $menu = $script:qualityMenus[$q]
        $menu.Tag = $q
        $menu.IsChecked = ($q -eq $Quality)
        $menu.Add_Click({
            param($s,$e)
            $script:quality = $s.Tag
            foreach ($m in $script:qualityMenus.Values) { $m.IsChecked = ($m -eq $s) }
            if ($script:recording) { $script:recorder.MarkSettingsChanged($script:quality) }
        })
    }
    $script:scaleValue = $Scale
    $script:scaleMenus = @{
        0.5 = $window.FindName("MenuS50")
        0.75 = $window.FindName("MenuS75")
        1.0 = $window.FindName("MenuS100")
    }
    foreach ($s in $script:scaleMenus.Keys) {
        $menu = $script:scaleMenus[$s]
        $menu.Tag = $s
        $menu.IsChecked = ($s -eq $Scale)
        $menu.Add_Click({
            param($sender,$e)
            if ($script:recording) { return }
            $script:scaleValue = $sender.Tag
            foreach ($m in $script:scaleMenus.Values) { $m.IsChecked = ($m -eq $sender) }
            Update-CaptureRegion
        })
    }
    $script:fpsValue = $FPS
    $script:fpsMenus = @{
        1 = $window.FindName("MenuF1")
        2 = $window.FindName("MenuF2")
        5 = $window.FindName("MenuF5")
        10 = $window.FindName("MenuF10")
    }
    foreach ($f in $script:fpsMenus.Keys) {
        $menu = $script:fpsMenus[$f]
        $menu.Tag = $f
        $menu.IsChecked = ($f -eq $FPS)
        $menu.Add_Click({
            param($sender,$e)
            if ($script:recording) { return }
            $script:fpsValue = $sender.Tag
            foreach ($m in $script:fpsMenus.Values) { $m.IsChecked = ($m -eq $sender) }
        })
    }
    $script:invisible = $false
    $menuExit.Add_Click({ $window.Close() })
    $menuInvisible.Add_Checked({ $script:invisible = $true })
    $menuInvisible.Add_Unchecked({ $script:invisible = $false; $mainBorder.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null); $mainBorder.Opacity = 1 })
    $window.ContextMenu.Add_Closed({ if ($script:invisible -and -not $window.IsMouseOver) { $mainBorder.BeginAnimation([System.Windows.UIElement]::OpacityProperty, [System.Windows.Media.Animation.DoubleAnimation]::new(0, [TimeSpan]::FromMilliseconds(100))) } })
    $menuExit.Parent.Add_KeyDown({ param($s,$e)
        if ($e.Key -eq 'X') { $window.Close() }
        if ($e.Key -eq 'A') { $menuInvisible.IsChecked = -not $menuInvisible.IsChecked }
    })
    $window.Add_MouseLeftButtonDown({ $window.DragMove() })
    $window.Add_Deactivated({ $window.Topmost = $true })
    $window.Add_MouseEnter({ if ($script:invisible) { $mainBorder.BeginAnimation([System.Windows.UIElement]::OpacityProperty, [System.Windows.Media.Animation.DoubleAnimation]::new(1, [TimeSpan]::FromMilliseconds(100))) } })
    $window.Add_MouseLeave({ if ($script:invisible -and -not $window.ContextMenu.IsOpen -and -not ($script:monitorMenu -and $script:monitorMenu.IsOpen) -and -not $script:overlayVisible) { $mainBorder.BeginAnimation([System.Windows.UIElement]::OpacityProperty, [System.Windows.Media.Animation.DoubleAnimation]::new(0, [TimeSpan]::FromMilliseconds(100))) } })
    function Update-FontSize($size) {
        if ($size -lt 12 -or $size -gt 200) { return }
        $clock.FontSize = $size
        $btnToggle.FontSize = $size * 0.35
        $btnToggle.Width = $size * 1.4
        $btnToggle.Height = $size * 0.7
        $btnToggle.Margin = [System.Windows.Thickness]::new($size * 0.25, 0, 0, 0)
        $recCounter.FontSize = $size * 0.35; $recCounter.Height = $size * 0.45
        $recSpacer.FontSize = $size * 0.35; $recSpacer.Height = $size * 0.45
        $script:monitorLabel.FontSize = $size * 0.3
        $script:monitorLabel.Margin = [System.Windows.Thickness]::new($size * 0.25, 0, 0, 0)
        $mainBorder.Padding = [System.Windows.Thickness]::new($size * 0.3, $size * 0.2, $size * 0.3, $size * 0.2)
    }
    $window.Add_MouseWheel({ param($s,$e)
        Update-FontSize ($clock.FontSize + ($e.Delta / 30))
    })

    # Settings file
    $script:settingsPath = [System.IO.Path]::Combine($env:APPDATA, 'ScreenRecorder', 'settings.json')

    function Save-Settings {
        $settings = @{
            Left = $window.Left
            Top = $window.Top
            FontSize = $clock.FontSize
            AutoHide = $script:invisible
            SelectedMonitors = $script:selectedMonitors
            Quality = $script:quality
            Scale = $script:scaleValue
            FPS = $script:fpsValue
            MonitorCount = $script:screens.Count
        }
        $dir = [System.IO.Path]::GetDirectoryName($script:settingsPath)
        if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        $settings | ConvertTo-Json | Set-Content -Path $script:settingsPath -Encoding UTF8
    }

    function Load-Settings {
        if (-not (Test-Path $script:settingsPath)) { return $null }
        try { Get-Content -Path $script:settingsPath -Raw | ConvertFrom-Json } catch { Write-Warning "Failed to load settings: $_"; $null }
    }

    # Monitor setup
    $script:screens = [System.Windows.Forms.Screen]::AllScreens

    function Get-PhysicalBounds($screen) {
        $dm = New-Object DisplayHelper+DEVMODE
        $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)
        [DisplayHelper]::EnumDisplaySettings($screen.DeviceName, [DisplayHelper]::ENUM_CURRENT_SETTINGS, [ref]$dm) | Out-Null
        [System.Drawing.Rectangle]::new($dm.dmPositionX, $dm.dmPositionY, $dm.dmPelsWidth, $dm.dmPelsHeight)
    }
    # Selected monitors (array of indices)
    $primaryIdx = [Array]::IndexOf($script:screens, [System.Windows.Forms.Screen]::PrimaryScreen)
    $script:selectedMonitors = @($(if ($primaryIdx -ge 0) { $primaryIdx } else { 0 }))

    function Update-MonitorLabel {
        if ($script:selectedMonitors.Count -eq 0) {
            $script:monitorLabel.Text = "None"
        } elseif ($script:selectedMonitors.Count -eq 1) {
            $idx = $script:selectedMonitors[0]
            $script:monitorLabel.Text = if ($script:screens[$idx].Primary) { "Mon $($idx+1)*" } else { "Mon $($idx+1)" }
        } else {
            $nums = ($script:selectedMonitors | Sort-Object | ForEach-Object { $_ + 1 }) -join '+'
            $script:monitorLabel.Text = "Mon $nums"
        }
    }

    function Update-CaptureRegion {
        if ($script:selectedMonitors.Count -eq 0) { return }
        # Calculate bounding rectangle of all selected monitors
        $minX = [int]::MaxValue; $minY = [int]::MaxValue
        $maxX = [int]::MinValue; $maxY = [int]::MinValue
        foreach ($idx in $script:selectedMonitors) {
            $b = Get-PhysicalBounds $script:screens[$idx]
            if ($b.Left -lt $minX) { $minX = $b.Left }
            if ($b.Top -lt $minY) { $minY = $b.Top }
            if ($b.Right -gt $maxX) { $maxX = $b.Right }
            if ($b.Bottom -gt $maxY) { $maxY = $b.Bottom }
        }
        $script:bounds = [System.Drawing.Rectangle]::new($minX, $minY, $maxX - $minX, $maxY - $minY)
        $script:w = [int]($script:bounds.Width * $script:scaleValue)
        $script:h = [int]($script:bounds.Height * $script:scaleValue)
    }

    function Show-MonitorOverlay {
        param([switch]$ReopenMenu)
        $script:pendingMenuReopen = $ReopenMenu.IsPresent
        $script:overlayVisible = $true
        $dpiScale = [System.Windows.Forms.SystemInformation]::VirtualScreen.Width / [System.Windows.SystemParameters]::VirtualScreenWidth
        $overlays = @()
        for ($i = 0; $i -lt $script:screens.Count; $i++) {
            $scr = $script:screens[$i]
            $isSelected = $script:selectedMonitors -contains $i
            $wpfLeft = $scr.Bounds.Left / $dpiScale
            $wpfTop = $scr.Bounds.Top / $dpiScale
            $wpfWidth = $scr.Bounds.Width / $dpiScale
            $wpfHeight = $scr.Bounds.Height / $dpiScale
            [xml]$overlayXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    WindowStyle="None" AllowsTransparency="True" Topmost="True"
    Background="$( if ($isSelected) { '#88004488' } else { '#88000000' } )"
    Left="$wpfLeft" Top="$wpfTop" Width="$wpfWidth" Height="$wpfHeight">
    <Grid>
        <TextBlock Text="$($i+1)" FontSize="300" FontWeight="Bold"
            Foreground="$( if ($isSelected) { '#AAFFFFFF' } else { '#44FFFFFF' } )"
            HorizontalAlignment="Center" VerticalAlignment="Center"/>
    </Grid>
</Window>
"@

            $overlay = [Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($overlayXaml))
            $overlay.Add_MouseLeftButtonDown({
                param($s,$e)
                $s.Close()
            })
            $overlay.Add_Closed({
                $script:overlayCloseCount++
                if ($script:overlayCloseCount -ge $script:overlayTotal) {
                    $script:overlayVisible = $false
                    if ($script:pendingMenuReopen) {
                        $script:pendingMenuReopen = $false
                        $script:monitorMenu.PlacementTarget = $script:monitorLabel
                        $script:monitorMenu.Placement = [System.Windows.Controls.Primitives.PlacementMode]::Bottom
                        $script:monitorMenu.IsOpen = $true
                    }
                }
            })
            $overlay.Show()
            $overlays += $overlay
        }
        $script:overlayCloseCount = 0
        $script:overlayTotal = $overlays.Count
        # Auto close after 1.5 seconds
        $timer = [System.Windows.Threading.DispatcherTimer]::new()
        $timer.Interval = [TimeSpan]::FromMilliseconds(1500)
        $timer.Add_Tick({
            $timer.Stop(); $timer.IsEnabled = $false
            foreach ($o in $overlays) { if ($o.IsVisible) { $o.Close() } }
        }.GetNewClosure())
        $timer.Start()
    }

    if ($script:screens.Count -gt 1) {
        $script:monitorLabel.Visibility = "Visible"
        Update-MonitorLabel

        # Create context menu with checkboxes
        $script:monitorMenu = [System.Windows.Controls.ContextMenu]::new()
        $script:monitorMenu.StaysOpen = $true
        for ($i = 0; $i -lt $script:screens.Count; $i++) {
            $menuItem = [System.Windows.Controls.MenuItem]::new()
            $menuItem.Header = if ($script:screens[$i].Primary) { "Mon $($i+1)*" } else { "Mon $($i+1)" }
            $menuItem.IsCheckable = $true
            $menuItem.IsChecked = $script:selectedMonitors -contains $i
            $menuItem.Tag = $i
            $menuItem.StaysOpenOnClick = $true
            $menuItem.Add_Click({
                param($sender, $e)
                $idx = $sender.Tag
                if ($sender.IsChecked) {
                    if ($script:selectedMonitors -notcontains $idx) {
                        $script:selectedMonitors += $idx
                    }
                } else {
                    # Prevent unchecking the last one
                    if ($script:selectedMonitors.Count -le 1) {
                        $sender.IsChecked = $true
                        return
                    }
                    $script:selectedMonitors = @($script:selectedMonitors | Where-Object { $_ -ne $idx })
                }
                Update-MonitorLabel
                Update-CaptureRegion
            })
            $script:monitorMenu.Items.Add($menuItem) | Out-Null
        }

        $script:monitorLabel.ContextMenu = $script:monitorMenu
        $script:monitorLabel.Add_MouseLeftButtonDown({
            param($sender, $e)
            if ($script:recording) {
                # Show menu only (no overlay) during recording
                $script:monitorMenu.PlacementTarget = $script:monitorLabel
                $script:monitorMenu.Placement = [System.Windows.Controls.Primitives.PlacementMode]::Bottom
                $script:monitorMenu.IsOpen = $true
            } else {
                Show-MonitorOverlay -ReopenMenu
            }
            $e.Handled = $true
        })
    }

    $script:recording = $false
    $script:outDir = $null
    $script:recordFor = $RecordFor
    $script:recordStartTime = $null

    # Load saved settings
    $savedSettings = Load-Settings
    if ($savedSettings) {
        # Check if monitor configuration changed
        $monitorConfigChanged = $savedSettings.MonitorCount -ne $script:screens.Count

        # Window position (skip if monitor config changed)
        if (-not $monitorConfigChanged) {
            if ($null -ne $savedSettings.Left) { $window.Left = $savedSettings.Left }
            if ($null -ne $savedSettings.Top) { $window.Top = $savedSettings.Top }
        }
        # Font size
        if ($savedSettings.FontSize) {
            Update-FontSize $savedSettings.FontSize
        }
        # Auto-hide
        if ($savedSettings.AutoHide) { $menuInvisible.IsChecked = $true }
        # Quality
        if ($savedSettings.Quality -ge 1 -and $savedSettings.Quality -le 100) {
            $script:quality = $savedSettings.Quality
            foreach ($m in $script:qualityMenus.Values) { $m.IsChecked = ($m.Tag -eq $script:quality) }
        }
        # Scale
        if ($savedSettings.Scale -ge 0.1 -and $savedSettings.Scale -le 1.0) {
            $script:scaleValue = $savedSettings.Scale
            foreach ($m in $script:scaleMenus.Values) { $m.IsChecked = ($m.Tag -eq $script:scaleValue) }
        }
        # FPS
        if ($savedSettings.FPS -in @(1, 2, 5, 10)) {
            $script:fpsValue = $savedSettings.FPS
            foreach ($m in $script:fpsMenus.Values) { $m.IsChecked = ($m.Tag -eq $script:fpsValue) }
        }
        # Selected monitors (skip if monitor config changed)
        if (-not $monitorConfigChanged -and $savedSettings.SelectedMonitors) {
            $validMonitors = @($savedSettings.SelectedMonitors | Where-Object { $_ -ge 0 -and $_ -lt $script:screens.Count })
            if ($validMonitors.Count -gt 0) {
                $script:selectedMonitors = $validMonitors
                # Update monitor menu checkboxes
                if ($script:monitorMenu) {
                    foreach ($item in $script:monitorMenu.Items) {
                        $item.IsChecked = $script:selectedMonitors -contains $item.Tag
                    }
                }
                Update-MonitorLabel
            }
        }
    }

    Update-CaptureRegion

    # Background recorder instance
    $script:recorder = [BackgroundRecorder]::new()

    # Get window handle for physical coordinate calculation
    $windowHelper = [System.Windows.Interop.WindowInteropHelper]::new($window)

    # Test write access to a directory
    function Test-WriteAccess {
        param([string]$Path)
        try {
            $testFile = Join-Path $Path ".sr_test_$PID"
            [System.IO.File]::WriteAllText($testFile, 'test')
            Remove-Item $testFile -Force
            return $true
        } catch {
            return $false
        }
    }

    $btnToggle.Add_Click({
        if (-not $script:recording) {
            # Check write access and determine output directory
            if ($OutputPath) {
                if (Test-WriteAccess $OutputPath) {
                    $baseDir = $OutputPath
                } else {
                    New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction SilentlyContinue | Out-Null
                    if (Test-WriteAccess $OutputPath) {
                        $baseDir = $OutputPath
                    } else {
                        [System.Windows.MessageBox]::Show("Output path is not writable: $OutputPath", "Error", "OK", "Error")
                        return
                    }
                }
                $script:outDir = Join-Path $baseDir (Get-Date -Format 'yyyyMMdd_HHmmss')
            } else {
                $currentDir = Get-Location
                if (Test-WriteAccess $currentDir) {
                    $baseDir = $currentDir.Path
                } else {
                    $baseDir = Join-Path $env:TEMP 'ScreenRecorder'
                    New-Item -ItemType Directory -Path $baseDir -Force -ErrorAction SilentlyContinue | Out-Null
                    [System.Windows.MessageBox]::Show("Current directory is not writable.`n`nSaving to: $baseDir", "Warning", "OK", "Warning")
                }
                $script:outDir = Join-Path $baseDir "ScreenCaptures\$(Get-Date -Format 'yyyyMMdd_HHmmss')"
            }
            # Start recording
            New-Item -ItemType Directory -Path $script:outDir -Force | Out-Null
            # Build monitor bounds array
            $monitorBoundsArray = @()
            foreach ($idx in $script:selectedMonitors) {
                $monitorBoundsArray += Get-PhysicalBounds $script:screens[$idx]
            }
            # Error callback for recording errors.
            # Use BeginInvoke (not Invoke) so the recorder task thread isn't blocked
            # waiting for the UI thread, which would deadlock when the click handler
            # calls _recorder.Stop() and Stop() waits on the very task we came from.
            $errorHandler = {
                param([string]$errorMsg)
                $window.Dispatcher.BeginInvoke([Action]{
                    [System.Windows.MessageBox]::Show("Recording error occurred:`n`n$errorMsg", "Recording Error", "OK", "Error")
                    if ($script:recording) {
                        $btnToggle.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Primitives.ButtonBase]::ClickEvent))
                    }
                }) | Out-Null
            }
            $started = $script:recorder.Start($script:bounds, [System.Drawing.Rectangle[]]$monitorBoundsArray, $script:w, $script:h, $script:fpsValue, $script:quality, $script:outDir, $SaveMasked, $script:scaleValue, $windowHelper.Handle, $errorHandler)
            if (-not $started) {
                [System.Windows.MessageBox]::Show("Recording failed: $($script:recorder.LastError)", "Error", "OK", "Error")
                Remove-Item -Path $script:outDir -Force -ErrorAction SilentlyContinue
                return
            }
            $script:recording = $true
            $btnToggle.Content = "■ STOP"
            $btnToggle.Foreground = [System.Windows.Media.Brushes]::Red
            $script:monitorLabel.Opacity = 0.5
            foreach ($m in $script:scaleMenus.Values) { $m.IsEnabled = $false }
            foreach ($m in $script:fpsMenus.Values) { $m.IsEnabled = $false }
            if ($script:monitorMenu) { foreach ($m in $script:monitorMenu.Items) { $m.IsEnabled = $false } }

            $recCounter.Text = "0"; $recCounter.Visibility = "Visible"
            if ($script:recordFor -gt [TimeSpan]::Zero) {
                $script:recordStartTime = [DateTime]::Now
                $recSpacer.Text = $script:recordFor.ToString('hh\:mm\:ss')
            }
        } else {
            # Stop recording
            $script:recorder.Stop()
            $script:recording = $false
            $script:monitorLabel.Opacity = 1.0
            foreach ($m in $script:scaleMenus.Values) { $m.IsEnabled = $true }
            foreach ($m in $script:fpsMenus.Values) { $m.IsEnabled = $true }
            if ($script:monitorMenu) { foreach ($m in $script:monitorMenu.Items) { $m.IsEnabled = $true } }

            $recCounter.Visibility = "Hidden"
            $recSpacer.Text = ""
            $script:recordStartTime = $null
            $script:recordFor = [TimeSpan]::Zero
            if ($script:stopTimer) { $script:stopTimer.Stop(); $script:stopTimer = $null }
            $btnToggle.Content = "● REC"
            $btnToggle.Foreground = [System.Windows.Media.Brushes]::White
            Start-Process explorer $script:outDir
        }
    })

    $clockTimer = New-Object System.Windows.Threading.DispatcherTimer
    $clockTimer.Interval = [TimeSpan]::FromMilliseconds(100)
    $clockTimer.Add_Tick({
        $clock.Text = (Get-Date).ToString("HH:mm:ss.f")
        if ($script:recording) {
            $recCounter.Text = $script:recorder.Saved
            if ($script:recordStartTime) {
                $elapsed = [DateTime]::Now - $script:recordStartTime
                $remaining = $script:recordFor - $elapsed
                if ($remaining -lt [TimeSpan]::Zero) { $remaining = [TimeSpan]::Zero }
                $recSpacer.Text = $remaining.ToString('hh\:mm\:ss')
            }
        }
    })
    $clockTimer.Start()
    $window.Add_Closed({
        $clockTimer.Stop()
        Save-Settings
        if ($script:recording) { $script:recorder.Stop(); Start-Process explorer $script:outDir }
    })
    if ($ReadyFile) { New-Item -Path $ReadyFile -ItemType File -Force | Out-Null }

    # Auto-start recording if -RecordFor is specified
    if ($RecordFor -gt [TimeSpan]::Zero) {
        # Hide window immediately if Auto-hide is enabled
        if ($script:invisible) {
            $mainBorder.Opacity = 0
        }
        $window.Add_ContentRendered({
            # Initialize clock and wait for UI rendering to complete
            $clock.Text = (Get-Date).ToString("HH:mm:ss.f")
            $window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Loaded, [Action]{
                $btnToggle.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Primitives.ButtonBase]::ClickEvent))
                # Set up auto-stop timer
                $script:stopTimer = [System.Windows.Threading.DispatcherTimer]::new()
                $script:stopTimer.Interval = $RecordFor
                $script:stopTimer.Add_Tick({
                    $script:stopTimer.Stop()
                    if ($script:recording) {
                        $btnToggle.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Primitives.ButtonBase]::ClickEvent))
                    }
                    $window.Close()
                })
                $script:stopTimer.Start()
            })
        })
    }

    $window.ShowDialog()
}

# Run only when invoked directly (not dot-sourced or imported as module)
if ($MyInvocation.InvocationName -notin '.', '') {
    Start-ScreenRecorder @PSBoundParameters
}