Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6bd9523
added plan
J-MaFf Mar 16, 2026
cb73b2c
feat: Add automated FFmpeg screen recording scripts for iKAT
J-MaFf Mar 16, 2026
e23a13d
refactor: Relocate DateFormat and RegionFormat scripts to iCat
J-MaFf Mar 16, 2026
f9e443c
chore: Add temporary utility scripts
J-MaFf Mar 16, 2026
ca6e906
feat: Add Invoke-iKATRecording.ps1 runner script
J-MaFf Mar 16, 2026
6f251e3
refactor: Clarify FFmpeg script execution naming conventions
J-MaFf Mar 16, 2026
3d6ad40
fix: Update target username in Start-DinaRecording runner
J-MaFf Mar 16, 2026
f2e5cad
docs: Add FFmpeg deployment testing guide
J-MaFf Mar 16, 2026
7aafc81
fix: Gracefully terminate FFmpeg via stdin
J-MaFf Mar 16, 2026
e04fe5c
fix: Add standard pixel format to FFmpeg capture
J-MaFf Mar 16, 2026
7fb3cf4
fix: Ensure FFmpeg terminates gracefully via try-finally
J-MaFf Mar 16, 2026
d36ddb1
test: Add local testing script and update testing instructions
J-MaFf Mar 16, 2026
f4848f3
docs: Update plan.md to reflect current script state
J-MaFf Mar 16, 2026
76f3b2f
Updated plan
J-MaFf Mar 16, 2026
68dcd0d
refactor: Record full session on logon across all monitors
J-MaFf Mar 24, 2026
1ffcdbb
test: Update Invoke-iKATRecording tests for session-based refactor
J-MaFf Mar 24, 2026
fd1c922
feat: Add VBScript launchers for hidden Task Scheduler execution
J-MaFf Mar 24, 2026
42aa1f3
Changed name from iCat to iKAT
J-MaFf Mar 26, 2026
1fb6e4a
Changed name from iCat to iKAT
J-MaFf Mar 26, 2026
4278182
Testing
J-MaFf Mar 26, 2026
b242ed8
Updated vbs launch scripts
J-MaFf Mar 26, 2026
7a0ea38
Fixing launcher script
J-MaFf Mar 26, 2026
2f68e57
fix: Add detailed debug logging to trace ffmpeg launch failure
J-MaFf Mar 26, 2026
fe5412c
fix: Capture ffmpeg stderr for debugging early exit on RDS
J-MaFf Mar 26, 2026
0c43e87
fix: Round screen dimensions to even numbers for libx264 compatibility
J-MaFf Mar 26, 2026
4d34e15
fix: Remove RedirectStandardError to prevent ffmpeg pipe buffer deadlock
J-MaFf Mar 26, 2026
4d0cf4f
fix: Reduce MKV cluster size to 1s so recordings survive abrupt proce…
J-MaFf Mar 26, 2026
3c00505
fix: Switch to fragmented MP4 for crash-safe recordings without index…
J-MaFf Mar 26, 2026
886643a
fix: Add explicit -f mp4 and -g 25 keyframe interval for fragmented MP4
J-MaFf Mar 26, 2026
5f22586
fix: Switch recording format to MPEG-TS to prevent data loss on abrup…
J-MaFf Mar 26, 2026
06067b8
fix: Let gdigrab auto-detect resolution and add flush_packets to mini…
J-MaFf Mar 26, 2026
8f405ac
fix: Add zerolatency tune and reduce GOP to 5 frames to eliminate enc…
J-MaFf Mar 26, 2026
8d94292
docs: Add March 26 implementation log entries
J-MaFf Mar 26, 2026
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
95 changes: 95 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Invoke-iKATRecording.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
param (
[Parameter(Mandatory = $true)]
[string]$TargetUser,

[Parameter(Mandatory = $true)]
[string]$OutputDir,

[Parameter(Mandatory = $false)]
[int]$MinFreeSpaceGB = 10,

[Parameter(Mandatory = $false)]
[string]$FFmpegPath = "ffmpeg.exe"
)

# 1. Check if the current user matches the target user
$logDir = $OutputDir
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
"$(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) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - User mismatch. Exiting." | Add-Content "$logDir\debug.log"
Exit
}

# Ensure the output directory exists
if (-not (Test-Path -Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}

$driveLetter = (Get-Item $OutputDir).Root.Name

# 2. Check for sufficient free disk space
$drive = Get-CimInstance -Class Win32_LogicalDisk -Filter "DeviceID='$($driveLetter.Trim('\'))'"
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) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Not enough free space. Exiting." | Add-Content "$logDir\debug.log"
Exit
}
}

# 3. Log the reported screen dimensions for reference
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Detecting screen dimensions..." | Add-Content "$logDir\debug.log"
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
}
}
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()
}
}
}
30 changes: 30 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Remove-ExpiredRecordings.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
param (
[Parameter(Mandatory = $true)]
[string]$OutputDir,

[Parameter(Mandatory = $true)]
[int]$DaysToKeep
)

if (-not (Test-Path -Path $OutputDir)) {
Write-Warning "Directory '$OutputDir' does not exist. Exiting."
Exit
}

$logFile = Join-Path -Path $OutputDir -ChildPath "CleanupLog.txt"
$cutoffDate = (Get-Date).AddDays(-$DaysToKeep)

# Scan for .ts (MPEG-TS) recordings older than the retention limit
$expiredFiles = Get-ChildItem -Path $OutputDir -Filter "*.ts" -File | Where-Object { $_.LastWriteTime -lt $cutoffDate }

foreach ($file in $expiredFiles) {
try {
Remove-Item -Path $file.FullName -Force -ErrorAction Stop
$logMessage = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Successfully deleted: $($file.Name)"
Add-Content -Path $logFile -Value $logMessage
}
catch {
$logMessage = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Error deleting $($file.Name): $($_.Exception.Message)"
Add-Content -Path $logFile -Value $logMessage
}
}
28 changes: 28 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Start-DinaRecording.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<#
.SYNOPSIS
Runner script to launch the FFmpeg recording for a specific user and process.
.DESCRIPTION
This script wrapper is designed to be executed by Task Scheduler on the RemoteApp/RDS server.
It calls Start-AppRecording.ps1 with the predefined parameters for the iKAT troubleshooting scenario.
#>

$ErrorActionPreference = 'Stop'

# Determine the path to the main recording script located in the same directory
$RecordingScript = Join-Path -Path $PSScriptRoot -ChildPath "Invoke-iKATRecording.ps1"

# Define the parameters for this specific troubleshooting scenario using splatting
$CaptureParameters = @{
TargetUser = "dpuerner" # The specific user encountering the issue
OutputDir = "C:\Recordings" # The directory where the .mkv files will be saved
MinFreeSpaceGB = 10 # Ensure at least 10GB of free space before recording
# FFmpegPath = "C:\Scripts\FFmpeg\ffmpeg.exe" # Uncomment and modify if ffmpeg is not in system PATH
}

if (-not (Test-Path $RecordingScript)) {
Write-Error "Could not find the recording script at: $RecordingScript"
Exit
}

# Execute the recording script with the parameters
& $RecordingScript @CaptureParameters
1 change: 1 addition & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Start-DinaRecording.vbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CreateObject("WScript.Shell").Run "pwsh.exe -WindowStyle Hidden -ExecutionPolicy Bypass -NonInteractive -File ""C:\PowerShellScripts\Scripts\iKAT\Invoke-FFmpegCapture\Start-DinaRecording.ps1""", 0, False
28 changes: 28 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Start-JoeyRecording.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<#
.SYNOPSIS
Runner script to launch the FFmpeg recording for a specific user and process.
.DESCRIPTION
This script wrapper is designed to be executed by Task Scheduler on the RemoteApp/RDS server.
It calls Start-AppRecording.ps1 with the predefined parameters for the iKAT troubleshooting scenario.
#>

$ErrorActionPreference = 'Stop'

# Determine the path to the main recording script located in the same directory
$RecordingScript = Join-Path -Path $PSScriptRoot -ChildPath "Invoke-iKATRecording.ps1"

# Define the parameters for this specific troubleshooting scenario using splatting
$CaptureParameters = @{
TargetUser = "admin-jmaffiola" # The specific user encountering the issue
OutputDir = "C:\Recordings" # The directory where the .mkv files will be saved
MinFreeSpaceGB = 1 # Lower threshold for local testing
# FFmpegPath = "C:\Scripts\FFmpeg\ffmpeg.exe" # Uncomment and modify if ffmpeg is not in system PATH
}

if (-not (Test-Path $RecordingScript)) {
Write-Error "Could not find the recording script at: $RecordingScript"
Exit
}

# Execute the recording script with the parameters
& $RecordingScript @CaptureParameters
1 change: 1 addition & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Start-JoeyRecording.vbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CreateObject("WScript.Shell").Run "pwsh.exe -WindowStyle Hidden -ExecutionPolicy Bypass -NonInteractive -File ""C:\PowerShellScripts\Scripts\iKAT\Invoke-FFmpegCapture\Start-JoeyRecording.ps1""", 0, False
13 changes: 13 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/Test-Recording.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
$TestCaptureParameters = @{
TargetUser = $env:USERNAME # Logs you instead of Dina
TargetProcess = "notepad" # Monitors for Notepad
OutputDir = "C:\Temp\RecordingsTest"
MinFreeSpaceGB = 1
#FFmpegPath = "C:\Scripts\FFmpeg\ffmpeg.exe" # <--- Update path here if needed!
}

# Ensure the core script path is correct
$scriptPath = "C:\Users\jmaffiola\Documents\Scripts\PowerShellScripts\Scripts\iCat\Invoke-FFmpegCapture\Invoke-iKATRecording.ps1"

# Execute the core script
& $scriptPath @TestCaptureParameters
30 changes: 30 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Monday, March 16, 2026 @ 2:54 PM:
I have a rough draft of my script. I am going to install FFmpeg on the iKat server, then try the script.
3:14 PM:
FFmpeg is installed and on the PATH for all users of the machine.
3:58 PM:
Left off with getting it to work via task scheduler. right now, it records the second the task starts.
Tuesday, March 24, 2026 @ 3:03 PM:
Nate asked me to change the program behavior; I need it to record the whole session from logon to logoff, rather than a specific program.
3:26 PM:
Specific description of what needs to be changed can be found below:

Trigger / Start Condition
Loop Structure
Display Capture
Summary of changes required
Remove the process-waiting loop and $TargetProcess parameter entirely.
FFmpeg args — drop the hardcoded -offset_x 0 -offset_y 0 -video_size 1920x1080. Instead, dynamically detect the full virtual desktop dimensions via [System.Windows.Forms.SystemInformation]::VirtualScreen.
Session termination — the try/finally already handles this correctly; when Task Scheduler kills the script on logoff, finally fires and sends "q" to FFmpeg.
Runner scripts — remove TargetProcess from Start-DinaRecording.ps1 and
Thursday, March 26, 2026 @ 1:02 PM
Changes have been made, implementing it on the iKat server now
1:30 PM:
Several issues discovered and fixed during server deployment:
- Username typo corrected: dpurner → dpuerner in all scripts
- .vbs launcher files had hardcoded dev machine paths; updated to C:\PowerShellScripts\Scripts\iKAT\Invoke-FFmpegCapture\
- Task Scheduler was not running ffmpeg in the user's interactive desktop session. Fixed by registering tasks with LogonType Interactive and RunLevel Limited. RunLevel Highest (elevation) also blocked gdigrab from accessing the desktop on RDS.
- Recording output was being cut off: the script was reading logical (DPI-scaled) pixel dimensions via SystemInformation.VirtualScreen and passing them to gdigrab, which expects physical pixels. This caused the capture to be cropped, missing the taskbar and right side of screen. Fixed by removing manual dimension detection and letting gdigrab auto-detect the full desktop, with a -vf scale filter to enforce even dimensions required by libx264.
- Switched output format from MP4 to MPEG-TS (.ts). MP4 requires a trailer written at the end to be playable; if ffmpeg is force-killed at logoff the file is corrupt. MPEG-TS writes self-contained 188-byte packets continuously with no trailer needed, so the file is fully playable up to the last written frame even after an abrupt kill.
- Recording was still losing 5-10 seconds at the end due to libx264's encoder lookahead buffer. With a 25-frame GOP, libx264 holds up to 5 seconds of frames in memory before flushing. Fixed by adding -tune zerolatency (disables lookahead/B-frames, each frame encodes and flushes immediately) and reducing GOP to -g 5 (1-second keyframe intervals at 5fps). Also added -flush_packets 1 to force an OS-level flush after every TS packet. Maximum data loss is now under 1 second.
- Dina's task (iKAT-Record-Dina) re-registered with RunLevel Limited to match Joey's working configuration.
61 changes: 61 additions & 0 deletions Scripts/iKAT/Invoke-FFmpegCapture/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Project Plan: Modular Automated RemoteApp Session Recording via FFmpeg

## 1. Project Overview

Deploy an automated, background screen recording solution using FFmpeg to capture intermittent application errors. The solution is designed to be highly modular, accepting parameters for the target user, the target process (for RemoteApp environments), and the output directory.

**Initial Use Case:** Capturing the full desktop session of user "dpuerner" (Dina) to diagnose intermittent "ikat" application crashes.

## 2. Prerequisites

* **FFmpeg**: Download the Windows executable (`ffmpeg.exe`) and place it in a secure, accessible directory (e.g., `C:\Scripts\FFmpeg\`).
* **Storage Location**: [Pending Developer Input] (Passed as a parameter).
* **Retention Policy**: [Pending Developer Input] (Passed as a parameter).
* **Permissions**: Administrative access to the target Remote Desktop Session Host.

## 3. Workflow

1. User establishes an RDP/RemoteApp connection.
2. Windows Task Scheduler detects the logon and launches a hidden PowerShell script wrapper (`Start-DinaRecording.ps1`), which splats and invokes the core logic (`Invoke-iKATRecording.ps1`).
3. The script verifies `$env:USERNAME` matches the targeted user.
4. The script checks for sufficient free disk space on the target drive to prevent storage exhaustion.
5. FFmpeg starts recording immediately — capturing the entire virtual desktop (all monitors) for the duration of the session.
6. The script blocks until the session ends (logoff, disconnect, or Task Scheduler kill signal).
7. On termination, the `finally` block sends `"q"` to FFmpeg via Standard Input, gracefully flushing and finalizing the `.mkv` file.
8. A separate daily task runs a cleanup script, passing arguments for the directory and retention days.

## 4. Implementation Details (Current State)

### Core Recording Script (`Invoke-iKATRecording.ps1`)

* Uses a `param()` block for arguments: `$TargetUser`, `$OutputDir`, `$FFmpegPath`, and `$MinFreeSpaceGB`. (`$TargetProcess` removed — recording is no longer app-triggered.)
* Validates user identity and verifies free space on the destination drive before recording.
* Dynamically detects the full virtual desktop geometry using `[System.Windows.Forms.SystemInformation]::VirtualScreen` to capture all connected monitors at their native combined resolution.
* Starts FFmpeg immediately on logon — no waiting for a specific process.
* Uses `.NET System.Diagnostics.ProcessStartInfo` instead of `Start-Process` to properly redirect Standard Input without opening visible console windows.
* FFmpeg argument string: `-f gdigrab -framerate 5 -offset_x <X> -offset_y <Y> -video_size <W>x<H> -i desktop -c:v libx264 -preset ultrafast -crf 30 -pix_fmt yuv420p`. (Note: `-pix_fmt yuv420p` is critical so native Windows media players can decode the MKV; geometry values are auto-detected at runtime.)
* Blocks using `while (-not $ffmpegProcess.HasExited)` and wraps in `try { ... } finally { ... }`. Ensures that on any termination (logoff, Task Scheduler kill, Ctrl+C), it sends `"q"` to FFmpeg to cleanly flush and finalize the recording.

### Cleanup Script (`Remove-ExpiredRecordings.ps1`)

* Scans `$OutputDir` for `.mkv` files with a `LastWriteTime` older than the specified `$DaysToKeep`.
* Configurable output directory and retention to prevent out-of-storage issues on the terminal servers.

### Testing & Verification (`test.md` & `Test-Recording.ps1`)

* Contains Pester tests to validate free space logic and correct recording initialization parameters (`Invoke-iKATRecording.Tests.ps1`).
* Local sandbox script (`Test-Recording.ps1`) runs the process against dummy apps like `mspaint` or `calc` to prevent UWP suspension mechanics (like `notepad` on Win11) from stalling the process closure loop.

## 5. Deployment Configuration (Task Scheduler)

Instructions for creating the tasks:

* **Task A (Recording)**:
* Security Options: "Run only when user is logged on" (Allows it to capture the graphical desktop session).
* Trigger: "At log on" for specific user "dpuerner".
* Action: `powershell.exe`
* Arguments: `-WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-FFmpegCapture\Start-DinaRecording.ps1"`
* **Task B (Cleanup)**:
* Trigger: Daily at an off-peak time (e.g., 2:00 AM).
* Action: `powershell.exe`
* Arguments: `-ExecutionPolicy Bypass -File "C:\Scripts\Invoke-FFmpegCapture\Remove-ExpiredRecordings.ps1" -OutputDir "C:\Recordings" -DaysToKeep 5`
Loading
Loading