Start-ScreenRecorder.ps1

<#PSScriptInfo
.VERSION 0.2.0
.GUID d47eab76-de84-454d-aead-8b61ed3335eb
.AUTHOR Yoshifumi Tsuda
.COPYRIGHT Copyright (c) 2025 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.
#>


param(
    [switch]$Background,
    [int]$FPS = 2,
    [double]$Scale = 1.0,
    [switch]$SaveMasked
)

function Start-ScreenRecorder {
    <#
    .SYNOPSIS
        Starts a screen recorder with a clock overlay for debugging and log correlation.
    .DESCRIPTION
        Captures screenshots at regular intervals while displaying a large clock overlay.
        Designed for correlating screen captures with log timestamps during bug reproduction.
        Requires no external dependencies - uses only PowerShell and .NET.
        Can be run directly without module installation.
    .PARAMETER Background
        Runs the recorder in a hidden background process.
    .PARAMETER FPS
        Frames per second for capture. Default is 2.
    .PARAMETER Scale
        Scale factor for captured images (0.1 to 1.0). Default is 1.0.
    .PARAMETER SaveMasked
        Saves masked images (with clock area blacked out) for debugging.
    .EXAMPLE
        Start-ScreenRecorder
        Starts the recorder in background mode.
    .EXAMPLE
        Start-ScreenRecorder -FPS 10 -Scale 0.75
        Starts with higher frame rate and larger output images.
    #>

    [CmdletBinding()]
    param(
        [Parameter(DontShow)]
        [switch]$Background,
        [int]$FPS = 2,
        [ValidateRange(0.1, 1.0)]
        [double]$Scale = 1.0,
        [switch]$SaveMasked
    )

    if (-not $Background) {
        $exe = (Get-Process -Id $PID).Path
        $scriptPath = $MyInvocation.MyCommand.ScriptBlock.File
        if (-not $scriptPath) { $scriptPath = $PSCommandPath }
        $args = "-NoProfile -WindowStyle Hidden -File `"$scriptPath`" -Background -FPS $FPS -Scale $Scale"
        if ($SaveMasked) { $args += " -SaveMasked" }
        Start-Process $exe -ArgumentList $args -WindowStyle Hidden
        return
    }

    Add-Type -AssemblyName PresentationFramework,System.Windows.Forms,System.Drawing
    Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
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;
}
"@

    [xml]$xaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    Topmost="True" AllowsTransparency="True" WindowStyle="None" Background="Transparent"
    SizeToContent="WidthAndHeight" Left="20" Top="20">
    <Window.ContextMenu>
        <ContextMenu>
            <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"/>
                <Button Name="BtnToggle" Content="● REC" Width="45" Height="22" FontSize="11" Margin="8,0,0,0" Background="#AA444444" Foreground="White" BorderThickness="0" Padding="0" VerticalAlignment="Center"/>
                <ComboBox Name="ComboMonitor" Width="50" Height="22" FontSize="10" Margin="4,0,0,0" VerticalAlignment="Center" 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")
    $comboMonitor = $window.FindName("ComboMonitor")
    $menuExit = $window.FindName("MenuExit")
    $menuExit.Add_Click({ $window.Close() })
    $menuExit.Parent.Add_KeyDown({ param($s,$e) if ($e.Key -eq 'X') { $window.Close() } })
    $window.Add_MouseLeftButtonDown({ $window.DragMove() })
    $window.Add_MouseWheel({ param($s,$e)
        $size = $clock.FontSize + ($e.Delta / 30)
        if ($size -ge 12 -and $size -le 200) {
            $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)
            $comboMonitor.FontSize = $size * 0.3
            $comboMonitor.Width = $size * 1.5
            $comboMonitor.Height = $size * 0.7
            $comboMonitor.Margin = [System.Windows.Thickness]::new($size * 0.12, 0, 0, 0)
            $mainBorder.Padding = [System.Windows.Thickness]::new($size * 0.3, $size * 0.2, $size * 0.3, $size * 0.2)
        }
    })

    # Monitor setup
    $script:screens = [System.Windows.Forms.Screen]::AllScreens
    $script:targetScreen = [System.Windows.Forms.Screen]::PrimaryScreen
    $script:dpiScale = [System.Windows.Forms.SystemInformation]::VirtualScreen.Width / [System.Windows.SystemParameters]::VirtualScreenWidth

    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)
    }
    if ($script:screens.Count -gt 1) {
        $comboMonitor.Visibility = "Visible"
        for ($i = 0; $i -lt $script:screens.Count; $i++) {
            $label = if ($script:screens[$i].Primary) { "Mon $($i+1)*" } else { "Mon $($i+1)" }
            $comboMonitor.Items.Add($label) | Out-Null
        }
        $comboMonitor.SelectedIndex = [Array]::IndexOf($script:screens, [System.Windows.Forms.Screen]::PrimaryScreen)
    }

    function Show-MonitorOverlay {
        $overlays = @()
        for ($i = 0; $i -lt $script:screens.Count; $i++) {
            $scr = $script:screens[$i]
            $phys = Get-PhysicalBounds $scr
            $isSelected = ($i -eq $comboMonitor.SelectedIndex)
            $wpfLeft = $phys.Left / $script:dpiScale
            $wpfTop = $phys.Top / $script:dpiScale
            $wpfWidth = $phys.Width / $script:dpiScale
            $wpfHeight = $phys.Height / $script: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.Show()
            $overlays += $overlay
        }
        # Auto close after 1.5 seconds
        $timer = [System.Windows.Threading.DispatcherTimer]::new()
        $timer.Interval = [TimeSpan]::FromMilliseconds(1500)
        $timer.Add_Tick({
            $timer.Stop()
            foreach ($o in $overlays) { if ($o.IsVisible) { $o.Close() } }
        }.GetNewClosure())
        $timer.Start()
    }

    $comboMonitor.Add_SelectionChanged({
        $script:targetScreen = $script:screens[$comboMonitor.SelectedIndex]
        $script:bounds = Get-PhysicalBounds $script:targetScreen
        $script:w = [int]($script:bounds.Width * $Scale)
        $script:h = [int]($script:bounds.Height * $Scale)
        Show-MonitorOverlay
    })

    $script:recording = $false
    $script:outDir = $null
    $script:saved = 0
    $script:prevHash = $null
    $script:bounds = Get-PhysicalBounds $script:targetScreen
    $script:w = [int]($script:bounds.Width * $Scale)
    $script:h = [int]($script:bounds.Height * $Scale)

    function Get-ImageHash($bmp, $excludeRect) {
        # Black out the clock area for hash calculation
        if ($excludeRect) {
            $g = [System.Drawing.Graphics]::FromImage($bmp)
            $g.FillRectangle([System.Drawing.Brushes]::Black,
                $excludeRect.Left, $excludeRect.Top,
                ($excludeRect.Right - $excludeRect.Left),
                ($excludeRect.Bottom - $excludeRect.Top))
            $g.Dispose()
        }
        $ms = [System.IO.MemoryStream]::new()
        $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Bmp)
        $hash = [System.Security.Cryptography.MD5]::Create().ComputeHash($ms.ToArray())
        $ms.Dispose()
        ([BitConverter]::ToString($hash) -replace '-','')
    }

    $recTimer = New-Object System.Windows.Threading.DispatcherTimer
    $recTimer.Interval = [TimeSpan]::FromMilliseconds([int](1000 / $FPS))
    $recTimer.Add_Tick({
        if (-not $script:recording) { return }
        try {
            $now = Get-Date
            $bmp = New-Object System.Drawing.Bitmap($script:bounds.Width, $script:bounds.Height)
            $g = [System.Drawing.Graphics]::FromImage($bmp)
            $g.CopyFromScreen($script:bounds.Location, [System.Drawing.Point]::Empty, $script:bounds.Size)
            $g.Dispose()
            $thumb = New-Object System.Drawing.Bitmap($script:w, $script:h)
            $g2 = [System.Drawing.Graphics]::FromImage($thumb)
            $g2.DrawImage($bmp, 0, 0, $script:w, $script:h)
            $g2.Dispose()
            $bmp.Dispose()

            # Calculate exclude rectangle for clock window (scaled, relative to target monitor)
            $excludeRect = @{
                Left   = [int](($window.Left * $script:dpiScale - $script:bounds.Left) * $Scale)
                Top    = [int](($window.Top * $script:dpiScale - $script:bounds.Top) * $Scale)
                Right  = [int]((($window.Left + $window.ActualWidth) * $script:dpiScale - $script:bounds.Left) * $Scale)
                Bottom = [int]((($window.Top + $window.ActualHeight) * $script:dpiScale - $script:bounds.Top) * $Scale)
            }

            # Create masked copy for hash calculation
            $masked = $thumb.Clone()
            $currHash = Get-ImageHash $masked $excludeRect

            if ($currHash -ne $script:prevHash) {
                $filename = $now.ToString("yyyyMMdd_HHmmss_f")
                $jpegCodec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }
                $qualityParam = [System.Drawing.Imaging.Encoder]::Quality
                $encoderParams = [System.Drawing.Imaging.EncoderParameters]::new(1)
                $encoderParams.Param[0] = [System.Drawing.Imaging.EncoderParameter]::new($qualityParam, 75L)
                $thumb.Save("$($script:outDir)\$filename.jpg", $jpegCodec, $encoderParams)
                if ($SaveMasked) {
                    $masked.Save("$($script:outDir)\${filename}_masked.jpg", $jpegCodec, $encoderParams)
                }
                $script:saved++
                $script:prevHash = $currHash
            }
            $masked.Dispose()
            $thumb.Dispose()
        } catch {
            # Ignore capture errors (e.g., monitor disconnected)
        }
    })

    $btnToggle.Add_Click({
        if (-not $script:recording) {
            # Check if current directory is a system folder
            $blocked = @($env:SystemRoot, "$env:SystemRoot\System32", $env:ProgramFiles, ${env:ProgramFiles(x86)})
            if ((Get-Location).Path -in $blocked) {
                [System.Windows.MessageBox]::Show("Cannot record in system folder. Please change to a working directory.", "Warning", "OK", "Warning")
                return
            }
            # Start recording
            $script:outDir = ".\ScreenCaptures\$(Get-Date -Format 'yyyyMMdd_HHmmss')"
            New-Item -ItemType Directory -Path $script:outDir -Force | Out-Null
            $script:recording = $true
            $script:saved = 0
            $script:prevHash = $null
            $btnToggle.Content = "■ STOP"
            $btnToggle.Foreground = [System.Windows.Media.Brushes]::Red
            $comboMonitor.IsEnabled = $false; $recTimer.Start()
        } else {
            # Stop recording
            $recTimer.Stop()
            $script:recording = $false; $comboMonitor.IsEnabled = $true
            $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") })
    $clockTimer.Start()
    $window.Add_Closed({ $clockTimer.Stop(); $recTimer.Stop() })
    $window.ShowDialog()
}

# Standalone execution
if ($MyInvocation.InvocationName -notin '.', '') {
    Start-ScreenRecorder @PSBoundParameters
}