[CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Label, [Parameter(Mandatory = $true)][string[]]$ViewerArguments, [ValidateSet('Spawn', 'Flight')][string]$CaptureMode = 'Spawn', [switch]$WaitForFlightDrain ) # Capture through the viewer's D3D back buffer; no desktop or window capture is used. # Capturer le tampon D3D du viewer; aucune capture du bureau ou de la fenetre n'est utilisee. Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Drawing $PluginRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../..')) $ViewerExe = Join-Path $PluginRoot 'Standalone/bin/VoxelForgeCoreStandalone.exe' $CaptureRoot = Join-Path $PSScriptRoot 'captures' $LogRoot = Join-Path $PSScriptRoot 'viewer_logs' [IO.Directory]::CreateDirectory($CaptureRoot) | Out-Null [IO.Directory]::CreateDirectory($LogRoot) | Out-Null if (-not ('VoxelForgeR9MemoryProbe' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class VoxelForgeR9MemoryProbe { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct MEMORYSTATUSEX { public uint dwLength, dwMemoryLoad; public ulong ullTotalPhys, ullAvailPhys, ullTotalPageFile, ullAvailPageFile; public ulong ullTotalVirtual, ullAvailVirtual, ullAvailExtendedVirtual; } [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX status); public static ulong AvailablePhysicalBytes() { var status = new MEMORYSTATUSEX(); status.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)); if (!GlobalMemoryStatusEx(ref status)) throw new InvalidOperationException("GlobalMemoryStatusEx failed."); return status.ullAvailPhys; } } '@ } if ([VoxelForgeR9MemoryProbe]::AvailablePhysicalBytes() -lt 4GB) { throw 'Viewer launch skipped: less than 4 GiB of free physical memory.' } if (Get-Process UnrealEditor* -ErrorAction SilentlyContinue) { throw 'Viewer launch skipped: an UnrealEditor process is still running.' } if (-not (Test-Path -LiteralPath $ViewerExe -PathType Leaf)) { throw "Standalone viewer is missing: $ViewerExe" } Add-Type -AssemblyName System.Drawing $SafeLabel = $Label -replace '[^A-Za-z0-9_-]', '_' $SequenceSuffix = if ($CaptureMode -eq 'Spawn') { 'spawn' } else { 'mid_sprint' } $CapturePrefix = Join-Path $CaptureRoot "${SafeLabel}_${SequenceSuffix}" $RunArguments = [Collections.Generic.List[string]]::new() foreach ($Argument in $ViewerArguments) { $RunArguments.Add($Argument) } if ($CaptureMode -eq 'Spawn') { $RunArguments.Add("--capture-spawn-sequence-prefix=$CapturePrefix") $RunArguments.Add('--quit-after-spawn-captures') $ExpectedIndices = @(0, 1, 2) $TimeLimitSeconds = 60 } else { $RunArguments.Add("--capture-flight-sequence-prefix=$CapturePrefix") if (-not $WaitForFlightDrain) { $RunArguments.Add('--flight-capture-close-index=1') } $ExpectedIndices = @(1) $TimeLimitSeconds = if ($WaitForFlightDrain) { 900 } else { 45 } } $StdoutPath = Join-Path $LogRoot "$SafeLabel`_$SequenceSuffix.stdout.log" $StderrPath = Join-Path $LogRoot "$SafeLabel`_$SequenceSuffix.stderr.log" $QuotedArguments = @($RunArguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) $ArgumentLine = $QuotedArguments -join ' ' $Process = Start-Process -FilePath $ViewerExe -ArgumentList $ArgumentLine ` -WorkingDirectory (Split-Path -Parent $ViewerExe) -PassThru -WindowStyle Normal ` -RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath $Started = [Diagnostics.Stopwatch]::StartNew() try { $Process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal $Process.ProcessorAffinity = [IntPtr]0xFFF while ($true) { $Process.Refresh() if ($Process.HasExited) { break } if ($Process.PrivateMemorySize64 -gt 8GB) { throw "Owned viewer PID $($Process.Id) exceeded the 8 GiB private-memory limit." } if ($Started.Elapsed.TotalSeconds -gt $TimeLimitSeconds) { throw "Owned viewer PID $($Process.Id) exceeded the $TimeLimitSeconds-second capture limit." } Start-Sleep -Milliseconds 200 } $Process.WaitForExit() $Process.Refresh() if ($Process.ExitCode -ne 0) { throw "Owned viewer PID $($Process.Id) exited with code $($Process.ExitCode)." } } catch { $Process.Refresh() if (-not $Process.HasExited) { [void]$Process.CloseMainWindow() if (-not $Process.WaitForExit(3000)) { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue $Process.WaitForExit() } } throw } function Save-DownscaledJpeg([string]$Source, [string]$Destination, [long]$Quality) { $Original = [Drawing.Image]::FromFile($Source) try { $Width = [Math]::Min(960, $Original.Width) $Height = [int][Math]::Round($Original.Height * $Width / $Original.Width) $Small = New-Object Drawing.Bitmap($Width, $Height) $Graphics = [Drawing.Graphics]::FromImage($Small) try { $Graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic $Graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality $Graphics.DrawImage($Original, 0, 0, $Width, $Height) $Codec = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' } | Select-Object -First 1 $Parameters = New-Object Drawing.Imaging.EncoderParameters(1) try { $Parameters.Param[0] = New-Object Drawing.Imaging.EncoderParameter( [Drawing.Imaging.Encoder]::Quality, $Quality) $Small.Save($Destination, $Codec, $Parameters) } finally { $Parameters.Dispose() } } finally { $Graphics.Dispose() $Small.Dispose() } } finally { $Original.Dispose() } } $ViewerLog = Get-Content -LiteralPath $StdoutPath -Raw foreach ($Index in $ExpectedIndices) { $PngPath = '{0}_{1:D2}.png' -f $CapturePrefix, $Index if (-not (Test-Path -LiteralPath $PngPath -PathType Leaf)) { throw "Viewer exited normally but did not write required back-buffer capture index $Index." } if ($CaptureMode -eq 'Spawn') { $CaptureTime = @('0.5s', '1.0s', '2.0s')[$Index] $JpegPath = Join-Path $CaptureRoot "${SafeLabel}_spawn_$CaptureTime.jpg" } else { $JpegPath = Join-Path $CaptureRoot "${SafeLabel}_mid_sprint.jpg" } Save-DownscaledJpeg $PngPath $JpegPath 72 if ((Get-Item -LiteralPath $JpegPath).Length -ge 300KB) { Save-DownscaledJpeg $PngPath $JpegPath 48 } $Jpeg = Get-Item -LiteralPath $JpegPath if ($Jpeg.Length -ge 300KB) { throw "Downscaled capture exceeds 300 KB: $($Jpeg.Name)" } Remove-Item -LiteralPath $PngPath -Force $Timing = [regex]::Match($ViewerLog, "\[(Spawn|Flight)SequenceCapture\] index=$Index elapsed_s=([0-9.]+)") $Elapsed = if ($Timing.Success) { $Timing.Groups[2].Value } else { 'missing' } Write-Host ('capture={0} elapsed_s={1} size={2} bytes' -f $Jpeg.Name, $Elapsed, $Jpeg.Length) } if ($CaptureMode -eq 'Flight') { # Keep only the reviewed mid-flight JPG; remove this run's unused raw D3D frames. # Garder uniquement le JPG de vol examine ; supprimer les tampons D3D bruts inutilises de ce run. foreach ($Index in 0..4) { $PngPath = '{0}_{1:D2}.png' -f $CapturePrefix, $Index if (Test-Path -LiteralPath $PngPath -PathType Leaf) { Remove-Item -LiteralPath $PngPath -Force } } } Write-Host ('viewer_closed=1 pid={0} exit_code={1} elapsed_s={2:0.000} priority=Normal affinity=0xFFF' -f $Process.Id, $Process.ExitCode, $Started.Elapsed.TotalSeconds)