diff --git a/Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1 b/Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1 index be02d06..d7b2c10 100644 --- a/Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1 +++ b/Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1 @@ -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 } @@ -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 } @@ -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() + } } } -} \ No newline at end of file + + $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 +} diff --git a/Tests/iCat/Invoke-iKATRecording.Tests.ps1 b/Tests/iCat/Invoke-iKATRecording.Tests.ps1 index 484d692..988f006 100644 --- a/Tests/iCat/Invoke-iKATRecording.Tests.ps1 +++ b/Tests/iCat/Invoke-iKATRecording.Tests.ps1 @@ -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' { diff --git a/Tests/iCat/Remove-ExpiredRecordings.Tests.ps1 b/Tests/iCat/Remove-ExpiredRecordings.Tests.ps1 index f950e2d..1b81e35 100644 --- a/Tests/iCat/Remove-ExpiredRecordings.Tests.ps1 +++ b/Tests/iCat/Remove-ExpiredRecordings.Tests.ps1 @@ -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 } @@ -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) @@ -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' } }