Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 96 additions & 44 deletions Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Forc
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Script started. USERNAME=$($env:USERNAME), TargetUser=$TargetUser" | Add-Content "$logDir\debug.log"

if ($env:USERNAME -ne $TargetUser) {
Write-Output "Current user '$($env:USERNAME)' does not match target user '$TargetUser'. Exiting."
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - User mismatch. Exiting." | Add-Content "$logDir\debug.log"
Exit
}
Expand All @@ -35,6 +36,7 @@ if ($drive) {
$freeSpaceGB = [math]::Round($drive.FreeSpace / 1GB, 2)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Free space: $freeSpaceGB GB, Required: $MinFreeSpaceGB GB" | Add-Content "$logDir\debug.log"
if ($freeSpaceGB -lt $MinFreeSpaceGB) {
Write-Warning "Not enough free space. $freeSpaceGB GB available, $MinFreeSpaceGB GB required."
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Not enough free space. Exiting." | Add-Content "$logDir\debug.log"
Exit
}
Expand All @@ -46,50 +48,100 @@ Add-Type -AssemblyName System.Windows.Forms
$virtualScreen = [System.Windows.Forms.SystemInformation]::VirtualScreen
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Reported VirtualScreen: $($virtualScreen.Width)x$($virtualScreen.Height) offset $($virtualScreen.X),$($virtualScreen.Y)" | Add-Content "$logDir\debug.log"

# 4. Prepare output file
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outputFile = Join-Path -Path $OutputDir -ChildPath "$($TargetUser)_session_$timestamp.ts"

# Construct the FFmpeg arguments
# - gdigrab with no -video_size/-offset: auto-detects the full desktop at true physical pixel dimensions,
# avoiding DPI scaling mismatches that caused the resolution to appear cropped on RDS sessions.
# - scale filter: rounds width and height down to even numbers required by libx264 (no resize, just crop 1px if odd).
# - tune zerolatency: disables libx264 lookahead and B-frames so each frame is encoded and released immediately.
# - g 5: 1-second keyframe interval (at 5fps); combined with zerolatency the encoder buffer stays under 1 second.
# - MPEG-TS: self-contained 188-byte packets, fully playable with no trailer needed.
# - flush_packets 1: flush every TS packet to disk immediately after encoding.
$ffmpegArgsStr = "-f gdigrab -framerate 5 -i desktop -c:v libx264 -preset ultrafast -tune zerolatency -crf 30 -pix_fmt yuv420p -g 5 -vf `"scale=trunc(iw/2)*2:trunc(ih/2)*2`" -flush_packets 1 -f mpegts `"$outputFile`""

# 5. Start FFmpeg silently but intercept StandardInput for graceful shutdown
$procInfo = New-Object System.Diagnostics.ProcessStartInfo
$procInfo.FileName = $FFmpegPath
$procInfo.Arguments = $ffmpegArgsStr
$procInfo.RedirectStandardInput = $true
$procInfo.UseShellExecute = $false
$procInfo.CreateNoWindow = $true

"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Launching ffmpeg: $ffmpegArgsStr" | Add-Content "$logDir\debug.log"
$ffmpegProcess = [System.Diagnostics.Process]::Start($procInfo)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - FFmpeg PID: $($ffmpegProcess.Id)" | Add-Content "$logDir\debug.log"

try {
# 6. Block until FFmpeg exits naturally or the script is interrupted (logoff/session end)
while (-not $ffmpegProcess.HasExited) {
Start-Sleep -Seconds 5
}
# 4. Restart guardrails for unexpected FFmpeg exits (ex: UAC secure desktop transitions)
$restartWindowMinutes = 10
$maxRestartCount = 5
$restartDelaySeconds = 5
$restartCount = 0
$restartWindowStart = Get-Date

function New-RecordingFilePath {
param(
[Parameter(Mandatory = $true)]
[string]$DestinationDir,

[Parameter(Mandatory = $true)]
[string]$UserName
)

$timestamp = Get-Date -Format "yyyyMMdd_HHmmss_fff"
return Join-Path -Path $DestinationDir -ChildPath "$($UserName)_session_$timestamp.ts"
}
finally {
# 7. Session ended or script was interrupted — terminate FFmpeg gracefully
if ($null -ne $ffmpegProcess -and -not $ffmpegProcess.HasExited) {
# Send 'q' to gracefully stop recording so the file header formatting writes correctly
$ffmpegProcess.StandardInput.WriteLine("q")

# Wait up to 10 seconds for it to write headers and close
$ffmpegProcess.WaitForExit(10000) | Out-Null

# Fallback if the process stubbornly hung
if (-not $ffmpegProcess.HasExited) {
$ffmpegProcess.Kill()

while ($true) {
# 4. Prepare output file
$outputFile = New-RecordingFilePath -DestinationDir $OutputDir -UserName $TargetUser

# Construct the FFmpeg arguments
# - gdigrab with no -video_size/-offset: auto-detects the full desktop at true physical pixel dimensions,
# avoiding DPI scaling mismatches that caused the resolution to appear cropped on RDS sessions.
# - scale filter: rounds width and height down to even numbers required by libx264 (no resize, just crop 1px if odd).
# - tune zerolatency: disables libx264 lookahead and B-frames so each frame is encoded and released immediately.
# - g 5: 1-second keyframe interval (at 5fps); combined with zerolatency the encoder buffer stays under 1 second.
# - MPEG-TS: self-contained 188-byte packets, fully playable with no trailer needed.
# - flush_packets 1: flush every TS packet to disk immediately after encoding.
$ffmpegArgsStr = "-f gdigrab -framerate 5 -i desktop -c:v libx264 -preset ultrafast -tune zerolatency -crf 30 -pix_fmt yuv420p -g 5 -vf `"scale=trunc(iw/2)*2:trunc(ih/2)*2`" -flush_packets 1 -f mpegts `"$outputFile`""

# 5. Start FFmpeg silently but intercept StandardInput for graceful shutdown
$procInfo = New-Object System.Diagnostics.ProcessStartInfo
$procInfo.FileName = $FFmpegPath
$procInfo.Arguments = $ffmpegArgsStr
$procInfo.RedirectStandardInput = $true
$procInfo.UseShellExecute = $false
$procInfo.CreateNoWindow = $true

"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Launching ffmpeg: $ffmpegArgsStr" | Add-Content "$logDir\debug.log"
try {
$ffmpegProcess = [System.Diagnostics.Process]::Start($procInfo)
}
catch {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Failed to start ffmpeg: $($_.Exception.Message)" | Add-Content "$logDir\debug.log"
throw
}
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - FFmpeg PID: $($ffmpegProcess.Id)" | Add-Content "$logDir\debug.log"

try {
# 6. Block until FFmpeg exits naturally or the script is interrupted (logoff/session end)
while (-not $ffmpegProcess.HasExited) {
Start-Sleep -Seconds 5
}
}
finally {
# 7. Session ended or script was interrupted — terminate FFmpeg gracefully
if ($null -ne $ffmpegProcess -and -not $ffmpegProcess.HasExited) {
# Send 'q' to gracefully stop recording so the file header formatting writes correctly
$ffmpegProcess.StandardInput.WriteLine("q")

# Wait up to 10 seconds for it to write headers and close
$ffmpegProcess.WaitForExit(10000) | Out-Null

# Fallback if the process stubbornly hung
if (-not $ffmpegProcess.HasExited) {
$ffmpegProcess.Kill()
}
}
}
}

$exitCode = $ffmpegProcess.ExitCode
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - FFmpeg exited with code $exitCode" | Add-Content "$logDir\debug.log"

if ($exitCode -eq 0) {
break
}

$now = Get-Date
if ($now -gt $restartWindowStart.AddMinutes($restartWindowMinutes)) {
$restartCount = 0
$restartWindowStart = $now
}

$restartCount += 1
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - FFmpeg exited unexpectedly. Restart $restartCount of $maxRestartCount within $restartWindowMinutes minutes." | Add-Content "$logDir\debug.log"

if ($restartCount -gt $maxRestartCount) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - FFmpeg exited repeatedly. Recording stopped." | Add-Content "$logDir\debug.log"
throw "FFmpeg exited repeatedly; aborting recording."
}

Start-Sleep -Seconds $restartDelaySeconds
}
2 changes: 1 addition & 1 deletion Tests/iCat/Invoke-iKATRecording.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Describe 'Invoke-iKATRecording.ps1' {

BeforeAll {
$sut = Resolve-Path "$PSScriptRoot\..\..\Scripts\iCat\Invoke-FFmpegCapture\Invoke-iKATRecording.ps1"
$sut = Resolve-Path "$PSScriptRoot\..\..\Scripts\iKAT\Invoke-FFmpegCapture\Invoke-iKATRecording.ps1"
}

It 'Should exit if current user does not match TargetUser' {
Expand Down
12 changes: 6 additions & 6 deletions Tests/iCat/Remove-ExpiredRecordings.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Describe 'Remove-ExpiredRecordings.ps1' {
BeforeAll {
$sut = Resolve-Path "$PSScriptRoot\..\..\Scripts\iCat\Invoke-FFmpegCapture\Remove-ExpiredRecordings.ps1"
$sut = Resolve-Path "$PSScriptRoot\..\..\Scripts\iKAT\Invoke-FFmpegCapture\Remove-ExpiredRecordings.ps1"
$testDir = Join-Path -Path $env:TEMP -ChildPath "FFmpegTest_$(New-Guid)"
New-Item -ItemType Directory -Path $testDir | Out-Null
}
Expand All @@ -21,13 +21,13 @@ Describe 'Remove-ExpiredRecordings.ps1' {
Assert-MockCalled Write-Warning -Times 1 -ParameterFilter { $Message -match "Directory '$escapedDir' does not exist" }
}

It 'Should delete .mkv files older than DaysToKeep and leave new files intact' {
It 'Should delete .ts files older than DaysToKeep and leave new files intact' {
# Create a 6-day-old file
$oldFile = New-Item -Path $testDir -Name "old_video.mkv" -ItemType File
$oldFile = New-Item -Path $testDir -Name "old_video.ts" -ItemType File
$oldFile.LastWriteTime = (Get-Date).AddDays(-6)

# Create a new file (1 day old)
$newFile = New-Item -Path $testDir -Name "new_video.mkv" -ItemType File
$newFile = New-Item -Path $testDir -Name "new_video.ts" -ItemType File
$newFile.LastWriteTime = (Get-Date).AddDays(-1)

# Create a 6-day-old file with a different extension (should be ignored)
Expand All @@ -53,8 +53,8 @@ Describe 'Remove-ExpiredRecordings.ps1' {
$logExists | Should -Be $true

$logContent = Get-Content $logFile
$logContent | Should -Match 'Successfully deleted: old_video.mkv'
$logContent | Should -Not -Match 'new_video.mkv'
$logContent | Should -Match 'Successfully deleted: old_video.ts'
$logContent | Should -Not -Match 'new_video.ts'
$logContent | Should -Not -Match 'old_notes.txt'
}
}