psScreenRecorder.psm1
<# Module Mixed by BarTender A Framework for making PowerShell Modules Version: 6.1.22 Author: Adrian.Andersson Copyright: 2019 Domain Group Module Details: Module: psScreenRecorder Description: Desktop Video Capture with PowerShell Revision: 1.0.7.9 Author: Adrian.Andersson Company: Adrian Andersson Check Manifest for more details #> function convert-mp4togif { <# .SYNOPSIS Use FFMPEG to convert an mp4 to a gif .DESCRIPTION Use FFMPEG to convert an mp4 to a gif .PARAMETER mp4Path path to the mp4File .PARAMETER gifPath path to export the gif file .PARAMETER ffMPegPath Path to ffMpeg Suggest you modify this to be where yours is by default .PARAMETER tempPath Where to keep the palette file .PARAMETER fps The FPS to use for the gif .PARAMETER scale Use this to set the scale Seems to be horizontal resolution .EXAMPLE convert-mp4togif -mp4Path c:\input.mp4 -gifPath c:\output.gif .NOTES Author: Adrian Andersson Changelog 2019-03-14 - AA - Initial Script 2019-06-20 - AA - Added FPS and Scale .COMPONENT psScreenCapture #> [CmdletBinding()] PARAM( [Parameter(Mandatory=$true,Position=0)] [Alias("path")] [string]$mp4Path, [Parameter(Mandatory=$true,Position=1)] [Alias("destination")] [string]$gifPath, [string]$ffMPegPath = $(get-childitem -path "$($env:ALLUSERSPROFILE)\ffmpeg" -filter 'ffmpeg.exe' -Recurse|sort-object -Property LastWriteTime -Descending|select-object -First 1).fullname, [string]$tempPath = "$($env:temp)\ffmpeg", [ValidateRange(1,60)] [int]$fps = 10, [int]$scale = 320 ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" }process{ write-verbose 'Checking for ffmpeg' if(!$(test-path -Path $ffMPegPath -ErrorAction SilentlyContinue)) { throw 'FFMPEG not found - either provide the path variable or run the install-ffmmpeg command' } if(!$(test-path $tempPath)) { write-verbose 'Creating ffmpeg temp directory' try{ $outputDir = new-item -ItemType Directory -Path $tempPath -Force -ErrorAction Stop write-verbose 'Directory Created' }catch{ throw 'Unable to create ffmpeg temp directory' } } write-verbose 'Checking the input MP4 file' if(!$(test-path $mp4Path)) { throw "Input MP4: $mp4Path not found" }else{ $mp4File = $(get-item $mp4Path) $mp4Path = $mp4File.FullName } if(!$mp4Path -or ($mp4File.extension -ne '.mp4')) { throw "Error parsing mp4path" } if(test-path $gifPath) { write-warning "$gifpath exists and will be removed" try{ remove-item $gifPath -Force }catch{ write-warning 'Unable to remove existing file' } } write-verbose 'Making Frames' $palettePath = "$($(get-item $tempPath).fullname)\palette.png" #$filters = "fps=$fps,scale=320:-1:flags=lanczos" #$scale = 1200 $filters = "fps=$fps,scale=$($scale):-1:flags=lanczos" $ffmpegArg = " -i $mp4Path -vf `"$filters,palettegen`" -y $($palettePath)" Start-Process -FilePath $ffMPegPath -ArgumentList $ffmpegArg -Wait write-verbose 'Creating GIF using palette' $ffmpegArg = " -i $($mp4Path) -i $($palettePath) -filter_complex `"$filters[x];[x][1:v]paletteuse`" $gifPath" Start-Process -FilePath $ffMPegPath -ArgumentList $ffmpegArg -Wait } } function install-ffMpeg { <# .SYNOPSIS Download ffmpeg .DESCRIPTION Download FFMPEG zip file Extract to allUsersProfile folder .PARAMETER ffmpegUri URI for where the zip file lives .PARAMETER tempPath Path to save the zip file .PARAMETER installPath Where to extract the zip file ------------ .EXAMPLE install-ffMpeg .NOTES Author: Adrian Andersson Changelog: 2019-03-14 - AA - Initial Script - Tested and working .COMPONENT psScreenRecorder #> [CmdletBinding()] PARAM( [string]$ffmpegUri = 'https://ffmpeg.zeranoe.com/builds/win64/static/ffmpeg-20190312-d227ed5-win64-static.zip', [string]$tempPath = "$($env:temp)\ffmpeg.zip", [string]$installPath = "$($env:ALLUSERSPROFILE)\ffmpeg" ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ write-verbose 'Download ffmpeg' try{ Invoke-WebRequest -Uri $ffmpegUri -OutFile $tempPath -ErrorAction Stop }catch{ throw 'Unable to download ffmpeg' } write-verbose 'Checking for install folder' if(!(test-path $installPath)) { try{ new-item -ItemType Directory -Path $installPath -Force }catch{ throw 'Unable to create installPath' } } write-verbose 'Uncompressing zip' try{ Expand-Archive -Path $tempPath -DestinationPath $installPath -Force }catch{ throw 'Unable to expand archive' } write-verbose 'Installation Complete' } end{ if(test-path $tempPath) { write-verbose 'Removing zip file' try{ remove-item $tempPath -Force write-verbose 'Zip file removed' }catch{ write-warning 'Unable to remove the ffmpeg zip file' } } } } function new-psScreenRecord { <# .SYNOPSIS Simple Screen-Capture done in PowerShell Needs ffmpeg: https://www.ffmpeg.org/ .DESCRIPTION Simple Screen-Capture done in PowerShell. Useful for making tutorial and demonstration videos Also draws a big red dot where your cursor is, if it is in the defined window bounds Uses FFMPeg to make a video file Video file can then be edited in your fav video editor Like Blender :) You will need to download and setup FFMPEG first https://www.ffmpeg.org/ The default path to the ffmpeg exe is c:\program files\ffmpeg\bin .PARAMETER videoName Name + Extension to output the video file as By default will use out.mp4 .PARAMETER fps Framerate used to calculate both how often to take a screenshot And what to use to process the ffmpeg call .PARAMETER captureCursor Should we put a replacement cursor (Red-dot for visibility) in the video? .PARAMETER force Skip fileExists and remove check .PARAMETER outFolder The folder to save the output video to .PARAMETER ffMPegPath Path to ffMpeg Suggest you modify this to be where yours is by default .PARAMETER tempPath Where to store the images before compiling them into a video .EXAMPLE new-psScreenRecord -outFolder 'C:\temp\testVid' -Verbose DESCRIPTION ------------ Will create a new video file with 'out.mp4' filename in c:\temp\testVid folder .NOTES Author: Adrian Andersson Changelog 2017-09-13 - AA - New script, cleaned-up from an old one I had saved 2019-03-14 - AA - Moved to bartender module 2019-03-14 - AA - Changed the ffmpegPath to use the allUsersProfile path - Throw better errors - Added a couple write-hosts so users were not left wondering what was going on with the capture process - Normally I don't condone write-host but it seemed to make sense in this case -Changed var name to ffmpegArg - Moved images to temp folder rather than output folder - Fixed confirm switch so it actually works - Fixed the help 2019-03-17 - AA - Second attempt at fixing screen scaling bug 2019-03-20 - AA - Added a switch and the necessary call changes to not capture the cursor if it is undesired - Removed the requirement to confirm - Changed the output folder to be in the users documents + psScreenRecorder subfolder - Old path was a bit untidy - Made confirm a 'force' switch as this is clearer language - Also it should only ask to confirm on removing the existing video file - Changed the way we check for files to be a bit tidier - Return the output video path as a string - Removed the write-hosts and made them write warning instead - Added a hidden param for startCapture - Can be used to skip the actual capture - Left it in for debug purposes - Re-ordered the params - Since videoName is the most important one now we have good defaults - If videoname does not end in .mp4, add it in - Added a check to see if mp4 is part of the video name, add it in if it isn't there .COMPONENT psScreenCapture #> [CmdletBinding()] PARAM( [Alias("name")] [string]$videoName = 'out.mp4', [Alias("framerate")] [string]$fps = 24, [bool]$captureCursor = $true, [switch]$force, [Alias("path")] [string]$outFolder = "$($env:USERPROFILE)\documents\psScreenRecorder", [string]$ffMPegPath = $(get-childitem -path "$($env:ALLUSERSPROFILE)\ffmpeg" -filter 'ffmpeg.exe' -Recurse|sort-object -Property LastWriteTime -Descending|select-object -First 1).fullname, [string]$tempPath = "$($env:temp)\ffmpeg", [Parameter(DontShow)] [bool]$startCapture = $true, [Parameter(DontShow)] [switch]$leaveImages ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" Write-Verbose 'Adding a new C# Assembly to get the Foreground Window' #This assembly is needed to get the current process #So we know when we have gone BACK to PowerShell #Use an array since its tidier than a here string $typeDefinition = @( 'using System;', 'using System.Runtime.InteropServices;', 'public class UserWindows {', ' [DllImport("user32.dll")]', ' public static extern IntPtr GetForegroundWindow();', '}' ) Add-Type $($typeDefinition -join "`n") write-verbose 'Loading other required assemblies' Add-Type -AssemblyName system.drawing add-type -AssemblyName system.windows.forms #We need to calculate the sleep-time based on the FPS #We want to know how many miliseconds to take a snap - as a whole number #Based on the frame-rate #This should be accurate enough write-verbose 'Calculating capture time' $msWait =[math]::Floor(1/$($fps/1000)) write-verbose 'Checking videoName has extension' if($videoName.EndsWith('.mp4') -ne $true) { Write-Verbose 'Appending mp4 extension to video name since it was not supplied' $videoName = "$videoName.mp4" } write-verbose 'Generating output path' $outputFilePath = "$outFolder\$videoName" write-verbose "outputFilePath: $outputFilePath" }process{ write-verbose 'Checking for ffmpeg' if(!$(test-path -Path $ffMPegPath -ErrorAction SilentlyContinue)) { throw 'FFMPEG not found - either provide the path variable or run the install-ffmmpeg command' } if(!$(test-path $tempPath)) { write-verbose 'Creating ffmpeg temp directory' try{ $outputDir = new-item -ItemType Directory -Path $tempPath -Force -ErrorAction Stop write-verbose 'Directory Created' }catch{ throw 'Unable to create ffmpeg temp directory' } }else{ Write-Verbose 'Removing existing jpegs in folder and video file if it exists' remove-item "$tempPath\*.jpg" -Force } Write-Verbose 'Getting THIS POWERSHELL Session handle number so we know what to ignore' #This is used in conjunction with the above service, to identify when we get back to the ps window $thisWindowHandle = $(Get-Process -Name *powershell* |Where-Object{$_.MainWindowHandle -eq $([userwindows]::GetForegroundWindow())}).MainWindowHandle Write-Verbose 'Ensuring output folder is ok' if(Test-Path $outfolder -ErrorAction SilentlyContinue) { Write-Verbose 'Output folder already exists.' if(test-path $outputFilePath) { if(!$force) { if($($Host.UI.PromptForChoice('Continue',"$outputFilePath already exists! Continue?", @('No','Yes'), 1)) -eq 1) { write-warning 'Removing file and continuing with screen capture' }else{ return -1 } remove-item $outputFilePath -Force -ErrorAction SilentlyContinue #SilentlyCont in case the file doesn't exist } } }else{ Write-Verbose 'Creating new output folder' new-item -Path $outFolder -ItemType Directory -Force } #Get the window size Write-Verbose 'Getting the Window Size' Read-Host 'VIDEO RECORD, put mouse cursor in top left corner of capture area and press any key' $start = [System.Windows.Forms.Cursor]::Position Read-Host 'VIDEO RECORD, put mouse cursor in bottom right corner of capture area and press any key' $end = [System.Windows.Forms.Cursor]::Position $scale = get-screenScaling $horStart = get-EvenNumber $($($start.x * $scale)) $verStart = get-EvenNumber $($($start.y * $scale)) $horEnd = get-EvenNumber $($($end.x * $scale)) $verEnd = get-EvenNumber $($($end.y * $scale)) $boxSize = "box size: Xa: $horStart, Ya: $verStart, Xb: $horEnd, Yb: $verEnd, $($horEnd - $horStart) pixels wide, $($verEnd - $verStart) pixles tall" Write-Verbose $boxSize #$startCapture = $true - Used to be used by confirm block #But will leave it in here to quickly switch off capturing for debug purposes #Wil move $startCapture = $true to be a hiidden boolean at the top though if($startCapture -eq $true -or $startCapture -eq 1) { Write-warning 'Starting screen capture 2 seconds after this window looses focus' #Start up the capture process $num = 1 #Iteration number for screenshot naming $capture = $false #Switch to say when to stop capture #Wait for PowerShell to loose focus while($capture -eq $false) { if([userwindows]::GetForegroundWindow() -eq $thisWindowHandle) { write-verbose 'Powershell still in focus' Start-Sleep -Milliseconds 60 }else{ write-verbose 'Powershell lost focus' Write-warning 'Focus Lost - Starting screen capture in 2 seconds' Start-Sleep -Seconds 2 Write-Warning 'Capturing Screen' $capture=$true $stopwatch = [System.Diagnostics.stopwatch]::StartNew() } } #Do another loop until PowerShell regains focus while($capture -eq $true) { if([userwindows]::GetForegroundWindow() -eq $thisWindowHandle) { write-verbose 'Powershell has regained focus, so exit the loop' $capture = $false }else{ write-verbose 'Powershell does not have focus, so capture a screenshot' $x = "{0:D5}" -f $num $path = "$tempPath\$x.jpg" $screenshotSplat = @{ horStart = $horStart vertStart = $verStart horEnd = $horEnd verEnd = $verEnd path = $path captureCursor = $captureCursor } #Out-screenshot -horStart $horStart -verStart $verStart -horEnd $horEnd -verEnd $verEnd -path $path -captureCursor out-screenShot @screenshotSplat $num++ Start-Sleep -milliseconds $msWait } } }else{ return -1 } }End{ $stopwatch.stop() $numberOfImages = $(get-childitem $tempPath -Filter '*.jpg').count #Gasp ... a write host appeared #Since we aren't returning any objects this seems like a good option #We are now returning objects, so this needs to be changed to a warning Write-warning 'Capture complete, compiling video' $actualFrameRate = $numberOfImages / $stopwatch.Elapsed.TotalSeconds $actualFrameRate = [math]::Ceiling($actualFrameRate) Write-Verbose "Time Elapsed: $($stopwatch.Elapsed.ToString())" Write-Verbose "Total Number of Images: $numberOfImages" Write-Verbose "ActualFrameRate: $actualFrameRate" Write-Verbose 'Creating video using ffmpeg' $ffmpegArg = "-framerate $actualFrameRate -i $tempPath\%05d.jpg -c:v libx264 -vf fps=$actualFrameRate -pix_fmt yuv420p $outputFilePath -y" Start-Process -FilePath $ffMPegPath -ArgumentList $ffmpegArg -Wait if(!$leaveImages) { Write-Verbose 'Cleaning up jpegs' remove-item "$tempPath\*.jpg" -Force }else{ write-warning "Leaving images in: $tempPath" } if(test-path $outputFilePath) { return $outputFilePath }else{ throw 'Error - Unable to find newly created file' } } } #Since libx264 needs easily divisible numbers, #Make a function that finds the nearest even number function get-EvenNumber { Param( [int]$number ) if($($number/2) -like '*.5') { $number = $number-1 } return $number } function get-screenScaling { <# .SYNOPSIS get the screen scale .DESCRIPTION get the screen scale .NOTES Author: Adrian Andersson Last-Edit-Date: 2019-03-15 Changelog: 2019-03-15 - AA - Initial Script - TypeDefinitiion from here: - https://hinchley.net/articles/get-the-scaling-rate-of-a-display-using-powershell/ 2019-03-17 - AA - Fixing bugs - Thanks to lazytao for raising this .COMPONENT What cmdlet does this script live in #> [CmdletBinding()] PARAM( ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $typeDefinition = @( 'using System;', 'using System.Runtime.InteropServices;', 'using System.Drawing;', '', 'public class DPI {', ' [DllImport("gdi32.dll")]', ' static extern int GetDeviceCaps(IntPtr hdc, int nIndex);', '', ' public enum DeviceCap {', ' VERTRES = 10,', ' DESKTOPVERTRES = 117', ' } ', '', ' public static float scaling() {', ' Graphics g = Graphics.FromHwnd(IntPtr.Zero);', ' IntPtr desktop = g.GetHdc();', ' int LogicalScreenHeight = GetDeviceCaps(desktop, (int)DeviceCap.VERTRES);', ' int PhysicalScreenHeight = GetDeviceCaps(desktop, (int)DeviceCap.DESKTOPVERTRES);', ' return (float)PhysicalScreenHeight / (float)LogicalScreenHeight;', ' }', '}' ) } process{ try{ write-verbose 'Getting DPI 1st Attempt' $dpi = [dpi]::scaling() }catch{ write-verbose 'Typedef missing, adding' Add-Type $($typeDefinition -join "`n") -ReferencedAssemblies 'System.Drawing.dll' write-verbose 'Getting DPI 2nd Attempt' $dpi = [dpi]::scaling() } if(!$dpi -or ($dpi -le 0)) { throw 'unable to get screen DPI' }else{ write-verbose 'Got screen dpi' $dpi } } } function Out-screenshot { param( [int]$verStart, [int]$horStart, [int]$verEnd, [int]$horEnd, [string]$path, [switch]$captureCursor ) $bounds = [drawing.rectangle]::FromLTRB($horStart,$verStart,$horEnd,$verEnd) $jpg = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.height $graphics = [drawing.graphics]::FromImage($jpg) $graphics.CopyFromScreen($bounds.Location,[Drawing.Point]::Empty,$bounds.Size) if($captureCursor) { write-verbose "CaptureCursor is true" $scale = get-screenScaling $mousePos = [System.Windows.Forms.Cursor]::Position $mouseX = $mousePos.x * $scale $mouseY = $mousePos.y * $scale if(($mouseX -gt $horStart)-and($mouseX -lt $horEnd)-and($mouseY -gt $verStart) -and ($mouseY -lt $verEnd)) { write-verbose "Mouse is in the box" #Get the position in the box $x = $mouseX - $horStart $y = $mouseY - $verStart write-verbose "X: $x, Y: $y" #Add a 4 pixel red-dot $pen = [drawing.pen]::new([drawing.color]::Red) $pen.width = 5 $pen.LineJoin = [Drawing.Drawing2D.LineJoin]::Bevel #$hand = [System.Drawing.SystemIcons]::Hand #$arrow = [System.Windows.Forms.Cursors]::Arrow #$graphics.DrawIcon($arrow, $x, $y) $graphics.DrawRectangle($pen,$x,$y, 5,5) #$mousePos } } $jpg.Save($path,"JPEG") } |