hypertwist/scripts/Test-HyperTwistPackagedOffscreenVisual.ps1
2026-07-23 08:42:30 +00:00

343 lines
11 KiB
PowerShell

param(
[Parameter(Mandatory = $true)]
[string]$ExecutablePath,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[string]$TargetMap = '',
[string[]]$ExpectedLogSubstring = @(),
[ValidateSet('Startup', 'RuntimeReady')]
[string]$CaptureTrigger = 'RuntimeReady',
[int]$TimeoutSeconds = 45,
[int]$SampleStride = 6,
[double]$MinimumNonBlackRatio = 0.02,
[double]$MinimumLuminanceDeviation = 2.5,
[double]$MinimumLuminanceRange = 20.0
)
$ErrorActionPreference = 'Stop'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
[System.IO.File]::WriteAllText(
$Path,
($Value | ConvertTo-Json -Depth 12),
(New-Object System.Text.UTF8Encoding($false))
)
}
function Update-HyperTwistOwnedProcessIds {
param(
[Parameter(Mandatory = $true)]
[System.Collections.Generic.HashSet[int]]$OwnedProcessIds,
[Parameter(Mandatory = $true)]
[string]$ProcessName,
[int[]]$IgnoredProcessIds = @()
)
foreach ($Process in @(Get-Process -Name $ProcessName -ErrorAction SilentlyContinue))
{
if ($IgnoredProcessIds -notcontains $Process.Id)
{
[void]$OwnedProcessIds.Add([int]$Process.Id)
}
}
}
function Measure-HyperTwistBitmap {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[int]$Stride = 6
)
Add-Type -AssemblyName System.Drawing
$Bitmap = [System.Drawing.Bitmap]::FromFile($Path)
try
{
$SafeStride = [Math]::Max($Stride, 1)
$SampleCount = 0
$NonBlackCount = 0
$LuminanceSum = 0.0
$LuminanceSquaredSum = 0.0
$MinimumLuminance = 255.0
$MaximumLuminance = 0.0
for ($Y = 0; $Y -lt $Bitmap.Height; $Y += $SafeStride)
{
for ($X = 0; $X -lt $Bitmap.Width; $X += $SafeStride)
{
$Pixel = $Bitmap.GetPixel($X, $Y)
$Luminance = (0.2126 * $Pixel.R) + (0.7152 * $Pixel.G) + (0.0722 * $Pixel.B)
$SampleCount += 1
$LuminanceSum += $Luminance
$LuminanceSquaredSum += ($Luminance * $Luminance)
$MinimumLuminance = [Math]::Min($MinimumLuminance, $Luminance)
$MaximumLuminance = [Math]::Max($MaximumLuminance, $Luminance)
if ($Luminance -gt 10.0)
{
$NonBlackCount += 1
}
}
}
if ($SampleCount -le 0)
{
throw 'The off-screen frame contained no image samples.'
}
$AverageLuminance = $LuminanceSum / $SampleCount
$Variance = [Math]::Max(
($LuminanceSquaredSum / $SampleCount) - ($AverageLuminance * $AverageLuminance),
0.0
)
return [ordered]@{
width = $Bitmap.Width
height = $Bitmap.Height
sampleStride = $SafeStride
sampleCount = $SampleCount
nonBlackRatio = $NonBlackCount / $SampleCount
averageLuminance = $AverageLuminance
luminanceDeviation = [Math]::Sqrt($Variance)
minimumLuminance = $MinimumLuminance
maximumLuminance = $MaximumLuminance
luminanceRange = $MaximumLuminance - $MinimumLuminance
}
}
finally
{
$Bitmap.Dispose()
}
}
if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf))
{
throw "HyperTwist executable '$ExecutablePath' does not exist."
}
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$ResolvedExecutablePath = (Resolve-Path -LiteralPath $ExecutablePath).Path
$ResolvedOutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path
$ExecutableDirectory = Split-Path -Parent $ResolvedExecutablePath
$ExecutableProcessName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedExecutablePath)
$SavedDirectory = Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved'
$ScreenshotDirectory = if ($CaptureTrigger -eq 'RuntimeReady')
{
Join-Path $SavedDirectory 'Screenshots\HyperTwistDiagnostics'
}
else
{
Join-Path $SavedDirectory 'Screenshots\Windows'
}
$RuntimeLogPath = Join-Path $SavedDirectory 'Logs\UnrealHyperTwist.log'
$CapturedFramePath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-frame.png'
$CapturedLogPath = Join-Path $ResolvedOutputDirectory 'UnrealHyperTwist.log'
$ReportPath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-visual-report.json'
$ExistingProcessIds = @(
Get-Process -Name $ExecutableProcessName -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Id
)
$ExistingScreenshotSignatures = @{}
foreach ($ExistingScreenshot in @(
Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue
))
{
$ExistingScreenshotSignatures[$ExistingScreenshot.FullName.ToLowerInvariant()] =
'{0}:{1}' -f $ExistingScreenshot.LastWriteTimeUtc.Ticks, $ExistingScreenshot.Length
}
$OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]'
$LaunchArguments = @()
if (-not [string]::IsNullOrWhiteSpace($TargetMap))
{
$LaunchArguments += $TargetMap
}
$LaunchArguments += @(
'-RenderOffScreen',
'-windowed',
'-ResX=1280',
'-ResY=720'
)
if ($CaptureTrigger -eq 'RuntimeReady')
{
$LaunchArguments += '-HyperTwistCaptureWhenReady'
}
else
{
$LaunchArguments += '-ExecCmds="SHOT SHOWUI"'
}
$Result = [ordered]@{
reportVersion = 'ht-packaged-offscreen-visual/v1'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
executablePath = $ResolvedExecutablePath
executableSha256 = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant()
targetMap = $TargetMap
captureTrigger = $CaptureTrigger
launchArguments = @($LaunchArguments)
expectedLogSubstrings = @($ExpectedLogSubstring)
matchedLogSubstrings = @()
screenshotPath = $CapturedFramePath
screenshotSha256 = $null
runtimeLogPath = $CapturedLogPath
metrics = $null
thresholds = [ordered]@{
minimumNonBlackRatio = $MinimumNonBlackRatio
minimumLuminanceDeviation = $MinimumLuminanceDeviation
minimumLuminanceRange = $MinimumLuminanceRange
}
processIds = @()
terminatedProcessIds = @()
result = 'failed'
error = $null
}
$LaunchedProcess = $null
try
{
$LaunchStartedAtUtc = [DateTime]::UtcNow
$LaunchedProcess = Start-Process `
-FilePath $ResolvedExecutablePath `
-ArgumentList $LaunchArguments `
-WorkingDirectory $ExecutableDirectory `
-PassThru
[void]$OwnedProcessIds.Add($LaunchedProcess.Id)
$Deadline = $LaunchStartedAtUtc.AddSeconds([Math]::Max($TimeoutSeconds, 1))
$FreshScreenshot = $null
$RuntimeLog = ''
while ([DateTime]::UtcNow -lt $Deadline)
{
Update-HyperTwistOwnedProcessIds `
-OwnedProcessIds $OwnedProcessIds `
-ProcessName $ExecutableProcessName `
-IgnoredProcessIds $ExistingProcessIds
$FreshScreenshot = Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue |
Where-Object {
$ScreenshotKey = $_.FullName.ToLowerInvariant()
$CurrentSignature = '{0}:{1}' -f $_.LastWriteTimeUtc.Ticks, $_.Length
-not $ExistingScreenshotSignatures.ContainsKey($ScreenshotKey) `
-or $ExistingScreenshotSignatures[$ScreenshotKey] -ne $CurrentSignature
} |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (Test-Path -LiteralPath $RuntimeLogPath)
{
$RuntimeLogItem = Get-Item -LiteralPath $RuntimeLogPath
if ($RuntimeLogItem.LastWriteTimeUtc -ge $LaunchStartedAtUtc.AddSeconds(-1))
{
$LoadedRuntimeLog = Get-Content -LiteralPath $RuntimeLogPath -Raw
$RuntimeLog = if ($null -eq $LoadedRuntimeLog)
{
''
}
else
{
[string]$LoadedRuntimeLog
}
}
}
$MatchedLogSubstrings = @(
$ExpectedLogSubstring |
Where-Object {
$RuntimeLog.IndexOf($_, [System.StringComparison]::Ordinal) -ge 0
}
)
$Result.matchedLogSubstrings = @($MatchedLogSubstrings)
if ($null -ne $FreshScreenshot `
-and $MatchedLogSubstrings.Count -eq $ExpectedLogSubstring.Count)
{
break
}
$LiveOwnedProcesses = @(
$OwnedProcessIds |
ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue } |
Where-Object { $null -ne $_ }
)
if ($LiveOwnedProcesses.Count -eq 0)
{
throw 'HyperTwist exited before off-screen visual validation completed.'
}
Start-Sleep -Milliseconds 500
}
if ($null -eq $FreshScreenshot)
{
throw "HyperTwist did not write a fresh UI-inclusive screenshot within $TimeoutSeconds seconds."
}
if ($Result.matchedLogSubstrings.Count -ne $ExpectedLogSubstring.Count)
{
$MissingLogSubstrings = @(
$ExpectedLogSubstring |
Where-Object { $Result.matchedLogSubstrings -notcontains $_ }
)
throw "The current runtime log omitted required markers: $($MissingLogSubstrings -join '; ')"
}
if ($RuntimeLog -match '(?im)Fatal error:|Unhandled Exception|Assertion failed|CreateSwapChainResult failed')
{
throw 'The current runtime log contains a fatal startup signature.'
}
Copy-Item -LiteralPath $FreshScreenshot.FullName -Destination $CapturedFramePath -Force
Copy-Item -LiteralPath $RuntimeLogPath -Destination $CapturedLogPath -Force
$Result.screenshotSha256 = (
Get-FileHash -LiteralPath $CapturedFramePath -Algorithm SHA256
).Hash.ToLowerInvariant()
$Result.metrics = Measure-HyperTwistBitmap -Path $CapturedFramePath -Stride $SampleStride
if ($Result.metrics.nonBlackRatio -lt $MinimumNonBlackRatio `
-or $Result.metrics.luminanceDeviation -lt $MinimumLuminanceDeviation `
-or $Result.metrics.luminanceRange -lt $MinimumLuminanceRange)
{
throw 'The UI-inclusive off-screen frame did not satisfy the configured visual thresholds.'
}
$Result.processIds = @($OwnedProcessIds | Sort-Object)
$Result.result = 'passed'
}
catch
{
$Result.error = $_.Exception.Message
}
finally
{
Update-HyperTwistOwnedProcessIds `
-OwnedProcessIds $OwnedProcessIds `
-ProcessName $ExecutableProcessName `
-IgnoredProcessIds $ExistingProcessIds
foreach ($ProcessId in @($OwnedProcessIds | Sort-Object -Descending))
{
$Process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
if ($null -ne $Process)
{
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
$Result.terminatedProcessIds += $ProcessId
}
}
Write-Utf8JsonFile -Path $ReportPath -Value $Result
}
if ($Result.result -ne 'passed')
{
throw $Result.error
}
Write-Output "HyperTwist off-screen visual validation passed: $ReportPath"