397 lines
15 KiB
PowerShell
397 lines
15 KiB
PowerShell
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ReplayPath,
|
|
[string]$ProjectRoot = 'C:\HyperTwist',
|
|
[string]$UnrealEditorCmdPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe',
|
|
[string]$OutputDirectory,
|
|
[string]$MapPath = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining',
|
|
[string]$LevelSequencePath = '/Game/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.LS_HyperTwist_ClassicReplayCapture',
|
|
[int]$OutputWidth = 1280,
|
|
[int]$OutputHeight = 720,
|
|
[int]$OutputFrameRate = 30,
|
|
[int]$TailDurationMs = 1500
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Format-HyperTwistMilliseconds {
|
|
param([int]$Milliseconds)
|
|
|
|
$SafeMilliseconds = [Math]::Max($Milliseconds, 0)
|
|
$Minutes = [int][Math]::Floor($SafeMilliseconds / 60000)
|
|
$Seconds = [int][Math]::Floor(($SafeMilliseconds / 1000) % 60)
|
|
$Remainder = [int]($SafeMilliseconds % 1000)
|
|
return '{0:D2}:{1:D2}.{2:D3}' -f $Minutes, $Seconds, $Remainder
|
|
}
|
|
|
|
function ConvertTo-HyperTwistFileStem {
|
|
param([string]$Value)
|
|
|
|
$Lowered = $Value.ToLowerInvariant()
|
|
$Sanitized = [regex]::Replace($Lowered, '[^a-z0-9]+', '-')
|
|
$Sanitized = [regex]::Replace($Sanitized, '-+', '-').Trim('-')
|
|
if ([string]::IsNullOrWhiteSpace($Sanitized)) {
|
|
return 'classic-cube-replay'
|
|
}
|
|
|
|
return $Sanitized
|
|
}
|
|
|
|
function Get-ClassicCubeReplayMetadata {
|
|
param([string]$ReplayFilePath)
|
|
|
|
$ReplayJson = Get-Content -LiteralPath $ReplayFilePath -Raw | ConvertFrom-Json
|
|
$ReplayId = [string]$ReplayJson.replayId
|
|
if ([string]::IsNullOrWhiteSpace($ReplayId)) {
|
|
$ReplayId = [string]$ReplayJson.sessionId
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ReplayId)) {
|
|
$ReplayId = 'classic-cube-replay'
|
|
}
|
|
|
|
$DerivedSummary = $ReplayJson.derivedSummary
|
|
$ReplayDurationMs = 0
|
|
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.totalTimeMs) {
|
|
$ReplayDurationMs = [int]$DerivedSummary.totalTimeMs
|
|
}
|
|
|
|
foreach ($Event in @($ReplayJson.events)) {
|
|
if ($null -ne $Event.timeMs) {
|
|
$ReplayDurationMs = [Math]::Max($ReplayDurationMs, [int]$Event.timeMs)
|
|
}
|
|
}
|
|
|
|
$MoveCount = 0
|
|
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.moveCount) {
|
|
$MoveCount = [int]$DerivedSummary.moveCount
|
|
}
|
|
|
|
$Tps = 0.0
|
|
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.tps) {
|
|
$Tps = [double]$DerivedSummary.tps
|
|
}
|
|
|
|
$Result = ''
|
|
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.result) {
|
|
$Result = [string]$DerivedSummary.result
|
|
}
|
|
|
|
$Metadata = $null
|
|
if ($null -ne $ReplayJson.annotationsJson -and -not [string]::IsNullOrWhiteSpace([string]$ReplayJson.annotationsJson)) {
|
|
$Metadata = ([string]$ReplayJson.annotationsJson | ConvertFrom-Json)
|
|
}
|
|
|
|
[pscustomobject]@{
|
|
ReplayId = $ReplayId
|
|
ReplayDurationMs = $ReplayDurationMs
|
|
MoveCount = $MoveCount
|
|
TPS = $Tps
|
|
Result = $Result
|
|
ScrambleNotation = if ($null -ne $Metadata) { [string]$Metadata.scrambleNotation } else { '' }
|
|
ScrambleLength = if ($null -ne $Metadata -and $null -ne $Metadata.scrambleLength) { [int]$Metadata.scrambleLength } else { 0 }
|
|
FinalTimeLabel = if ($Result -ieq 'dnf') { 'DNF' } else { Format-HyperTwistMilliseconds -Milliseconds $ReplayDurationMs }
|
|
}
|
|
}
|
|
|
|
function Invoke-ClassicCubeReplayExportRender {
|
|
param(
|
|
[string]$UProjectPath,
|
|
[string]$MapAssetPath,
|
|
[string]$ReplayFilePath,
|
|
[string]$OutputKind,
|
|
[string]$OutputBaseName,
|
|
[string]$OutputRoot,
|
|
[string]$SequenceAssetPath,
|
|
[int]$FrameRate,
|
|
[int]$Width,
|
|
[int]$Height,
|
|
[int]$CustomEndFrame,
|
|
[string]$ReportPath,
|
|
[double]$AutoQuitTailSeconds
|
|
)
|
|
|
|
$ExecutorArgs = @(
|
|
$UProjectPath,
|
|
$MapAssetPath,
|
|
'-game',
|
|
'-MoviePipelineLocalExecutorClass=/Script/MovieRenderPipelineCore.MoviePipelinePythonHostExecutor',
|
|
'-ExecutorPythonClass=/Engine/PythonTypes.HyperTwistMoviePipelineReplayExecutor',
|
|
"-LevelSequence=$SequenceAssetPath",
|
|
"-HyperTwistReplayPath=$ReplayFilePath",
|
|
"-HyperTwistRenderMapPath=$MapAssetPath",
|
|
'-HyperTwistDisableHud',
|
|
'-HyperTwistAutoQuitAfterReplay',
|
|
"-HyperTwistReplayAutoQuitTailSeconds=$AutoQuitTailSeconds",
|
|
"-HyperTwistOutputDirectory=$OutputRoot",
|
|
"-HyperTwistOutputBaseName=$OutputBaseName",
|
|
"-HyperTwistOutputKind=$OutputKind",
|
|
"-HyperTwistOutputWidth=$Width",
|
|
"-HyperTwistOutputHeight=$Height",
|
|
"-HyperTwistOutputFrameRate=$FrameRate",
|
|
'-HyperTwistCustomStartFrame=0',
|
|
"-HyperTwistCustomEndFrame=$CustomEndFrame",
|
|
"-HyperTwistExportReportPath=$ReportPath",
|
|
'-RenderOffScreen',
|
|
'-windowed',
|
|
"-ResX=$Width",
|
|
"-ResY=$Height",
|
|
'-unattended',
|
|
'-nop4',
|
|
'-nosplash',
|
|
'-stdout',
|
|
'-FullStdOutLogOutput',
|
|
'-log'
|
|
)
|
|
|
|
if (Test-Path -LiteralPath $ReportPath) {
|
|
Remove-Item -LiteralPath $ReportPath -Force
|
|
}
|
|
|
|
Get-ChildItem -LiteralPath $OutputRoot -Recurse -File -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.BaseName -like "$OutputBaseName*" } |
|
|
Remove-Item -Force -ErrorAction SilentlyContinue
|
|
|
|
& $UnrealEditorCmdPath @ExecutorArgs
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Replay export render '$OutputKind' failed with exit code $LASTEXITCODE."
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $ReportPath)) {
|
|
throw "Replay export render '$OutputKind' did not produce the expected report '$ReportPath'."
|
|
}
|
|
|
|
$RenderReport = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json
|
|
if (-not $RenderReport.success) {
|
|
throw "Replay export render '$OutputKind' reported failure in '$ReportPath'."
|
|
}
|
|
|
|
if (@($RenderReport.files).Count -le 0) {
|
|
throw "Replay export render '$OutputKind' produced no output files according to '$ReportPath'."
|
|
}
|
|
|
|
return $RenderReport
|
|
}
|
|
|
|
function Write-HyperTwistShareCard {
|
|
param(
|
|
[string]$BackgroundImagePath,
|
|
[string]$ShareCardPath,
|
|
[string]$Title,
|
|
[string]$Subtitle,
|
|
[string]$Footer
|
|
)
|
|
|
|
Add-Type -AssemblyName System.Drawing
|
|
|
|
$BackgroundImage = [System.Drawing.Image]::FromFile($BackgroundImagePath)
|
|
try {
|
|
$Bitmap = New-Object System.Drawing.Bitmap($BackgroundImage.Width, $BackgroundImage.Height)
|
|
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
|
|
try {
|
|
$Graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
|
$Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
$Graphics.DrawImage($BackgroundImage, 0, 0, $Bitmap.Width, $Bitmap.Height)
|
|
|
|
$OverlayHeight = [int]($Bitmap.Height * 0.34)
|
|
$OverlayTop = $Bitmap.Height - $OverlayHeight
|
|
$OverlayBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(192, 8, 13, 22))
|
|
$Graphics.FillRectangle($OverlayBrush, 0, $OverlayTop, $Bitmap.Width, $OverlayHeight)
|
|
$OverlayBrush.Dispose()
|
|
|
|
$AccentPen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(210, 245, 184, 65), 6)
|
|
$Graphics.DrawLine($AccentPen, 48, $OverlayTop + 24, $Bitmap.Width - 48, $OverlayTop + 24)
|
|
$AccentPen.Dispose()
|
|
|
|
$TitleFont = New-Object System.Drawing.Font('Segoe UI Semibold', 42)
|
|
$SubtitleFont = New-Object System.Drawing.Font('Segoe UI', 20)
|
|
$FooterFont = New-Object System.Drawing.Font('Segoe UI', 16)
|
|
$PrimaryBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 250, 250, 248))
|
|
$SecondaryBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(230, 232, 238, 246))
|
|
|
|
$Graphics.DrawString($Title, $TitleFont, $PrimaryBrush, 48, $OverlayTop + 42)
|
|
$Graphics.DrawString($Subtitle, $SubtitleFont, $SecondaryBrush, 48, $OverlayTop + 108)
|
|
$Graphics.DrawString($Footer, $FooterFont, $SecondaryBrush, 48, $OverlayTop + 146)
|
|
|
|
$TitleFont.Dispose()
|
|
$SubtitleFont.Dispose()
|
|
$FooterFont.Dispose()
|
|
$PrimaryBrush.Dispose()
|
|
$SecondaryBrush.Dispose()
|
|
|
|
$ShareDirectory = Split-Path -Parent $ShareCardPath
|
|
if (-not [string]::IsNullOrWhiteSpace($ShareDirectory)) {
|
|
New-Item -ItemType Directory -Force -Path $ShareDirectory | Out-Null
|
|
}
|
|
|
|
$Bitmap.Save($ShareCardPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
}
|
|
finally {
|
|
$Graphics.Dispose()
|
|
$Bitmap.Dispose()
|
|
}
|
|
}
|
|
finally {
|
|
$BackgroundImage.Dispose()
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $UnrealEditorCmdPath)) {
|
|
throw "UnrealEditor-Cmd.exe was not found at '$UnrealEditorCmdPath'."
|
|
}
|
|
|
|
$UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject'
|
|
if (-not (Test-Path -LiteralPath $UProjectPath)) {
|
|
throw "UnrealHyperTwist project file was not found at '$UProjectPath'."
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $ReplayPath)) {
|
|
throw "Replay file was not found at '$ReplayPath'."
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
|
$OutputDirectory = Join-Path $ProjectRoot 'Saved\MediaExport\ClassicCube'
|
|
}
|
|
|
|
$AuthoringScriptPath = Join-Path $ProjectRoot 'scripts\hypertwist_author_classic_cube_export_assets.py'
|
|
if (-not (Test-Path -LiteralPath $AuthoringScriptPath)) {
|
|
throw "Classic-cube export authoring script was not found at '$AuthoringScriptPath'."
|
|
}
|
|
|
|
$ReplayInfo = Get-ClassicCubeReplayMetadata -ReplayFilePath $ReplayPath
|
|
$OutputBaseName = 'classic-cube-{0}' -f (ConvertTo-HyperTwistFileStem -Value $ReplayInfo.ReplayId)
|
|
$ResolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)
|
|
$VideoDirectory = Join-Path $ResolvedOutputDirectory 'video'
|
|
$StillDirectory = Join-Path $ResolvedOutputDirectory 'still'
|
|
$ValidationDirectory = Join-Path $ResolvedOutputDirectory 'validation'
|
|
|
|
New-Item -ItemType Directory -Force -Path $VideoDirectory, $StillDirectory, $ValidationDirectory | Out-Null
|
|
|
|
$RenderEndFrame = [Math]::Max(
|
|
1,
|
|
[int][Math]::Ceiling((($ReplayInfo.ReplayDurationMs + $TailDurationMs) / 1000.0) * $OutputFrameRate)
|
|
)
|
|
$AutoQuitTailSeconds = [Math]::Round($TailDurationMs / 1000.0, 2)
|
|
|
|
$NormalizedAuthoringScriptPath = $AuthoringScriptPath -replace '\\', '/'
|
|
$AuthoringArgs = @(
|
|
$UProjectPath,
|
|
$MapPath,
|
|
"-ExecutePythonScript=$NormalizedAuthoringScriptPath",
|
|
'-NullRHI',
|
|
'-unattended',
|
|
'-nop4',
|
|
'-nosplash',
|
|
'-stdout',
|
|
'-FullStdOutLogOutput',
|
|
'-log'
|
|
)
|
|
|
|
& $UnrealEditorCmdPath @AuthoringArgs
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Replay export asset authoring failed with exit code $LASTEXITCODE."
|
|
}
|
|
|
|
$VideoReportPath = Join-Path $ValidationDirectory "$OutputBaseName-video-report.json"
|
|
$StillReportPath = Join-Path $ValidationDirectory "$OutputBaseName-still-report.json"
|
|
|
|
$VideoReport = Invoke-ClassicCubeReplayExportRender `
|
|
-UProjectPath $UProjectPath `
|
|
-MapAssetPath $MapPath `
|
|
-ReplayFilePath $ReplayPath `
|
|
-OutputKind 'video' `
|
|
-OutputBaseName $OutputBaseName `
|
|
-OutputRoot $VideoDirectory `
|
|
-SequenceAssetPath $LevelSequencePath `
|
|
-FrameRate $OutputFrameRate `
|
|
-Width $OutputWidth `
|
|
-Height $OutputHeight `
|
|
-CustomEndFrame $RenderEndFrame `
|
|
-ReportPath $VideoReportPath `
|
|
-AutoQuitTailSeconds $AutoQuitTailSeconds
|
|
|
|
$StillReport = Invoke-ClassicCubeReplayExportRender `
|
|
-UProjectPath $UProjectPath `
|
|
-MapAssetPath $MapPath `
|
|
-ReplayFilePath $ReplayPath `
|
|
-OutputKind 'still' `
|
|
-OutputBaseName "$OutputBaseName-still" `
|
|
-OutputRoot $StillDirectory `
|
|
-SequenceAssetPath $LevelSequencePath `
|
|
-FrameRate $OutputFrameRate `
|
|
-Width $OutputWidth `
|
|
-Height $OutputHeight `
|
|
-CustomEndFrame $RenderEndFrame `
|
|
-ReportPath $StillReportPath `
|
|
-AutoQuitTailSeconds $AutoQuitTailSeconds
|
|
|
|
$VideoFile = @($VideoReport.files | Where-Object { $_ -like '*.mp4' } | Select-Object -First 1)[0]
|
|
if ([string]::IsNullOrWhiteSpace($VideoFile) -or -not (Test-Path -LiteralPath $VideoFile)) {
|
|
throw "Replay export did not produce an MP4 output."
|
|
}
|
|
$RawVideoFile = $VideoFile
|
|
$CanonicalVideoFile = Join-Path $VideoDirectory "$OutputBaseName.mp4"
|
|
if ($RawVideoFile -ne $CanonicalVideoFile) {
|
|
Copy-Item -LiteralPath $RawVideoFile -Destination $CanonicalVideoFile -Force
|
|
}
|
|
$VideoFile = $CanonicalVideoFile
|
|
|
|
$StillFrameFile = @($StillReport.files | Where-Object { $_ -like '*.png' } | Select-Object -Last 1)[0]
|
|
if ([string]::IsNullOrWhiteSpace($StillFrameFile) -or -not (Test-Path -LiteralPath $StillFrameFile)) {
|
|
throw "Replay export did not produce a still-frame PNG output."
|
|
}
|
|
$RawStillFrameFile = $StillFrameFile
|
|
$CanonicalStillFrameFile = Join-Path $StillDirectory "$OutputBaseName-still.png"
|
|
if ($RawStillFrameFile -ne $CanonicalStillFrameFile) {
|
|
Copy-Item -LiteralPath $RawStillFrameFile -Destination $CanonicalStillFrameFile -Force
|
|
}
|
|
$StillFrameFile = $CanonicalStillFrameFile
|
|
|
|
$ShareCardPath = Join-Path $ResolvedOutputDirectory "$OutputBaseName-share.png"
|
|
$ShareSubtitle = if ($ReplayInfo.ScrambleLength -gt 0) {
|
|
'{0}-move scramble' -f $ReplayInfo.ScrambleLength
|
|
}
|
|
else {
|
|
'scramble metadata unavailable'
|
|
}
|
|
$ShareFooter = if ([string]::IsNullOrWhiteSpace($ReplayInfo.ScrambleNotation)) {
|
|
'{0} moves | {1:N2} TPS' -f $ReplayInfo.MoveCount, $ReplayInfo.TPS
|
|
}
|
|
else {
|
|
'{0} | {1} moves | {2:N2} TPS' -f $ReplayInfo.ScrambleNotation, $ReplayInfo.MoveCount, $ReplayInfo.TPS
|
|
}
|
|
|
|
Write-HyperTwistShareCard `
|
|
-BackgroundImagePath $StillFrameFile `
|
|
-ShareCardPath $ShareCardPath `
|
|
-Title $ReplayInfo.FinalTimeLabel `
|
|
-Subtitle $ShareSubtitle `
|
|
-Footer $ShareFooter
|
|
|
|
$AggregateReportPath = Join-Path $ValidationDirectory "$OutputBaseName-report.json"
|
|
$AggregateReport = [ordered]@{
|
|
reportVersion = 'ht-classic-cube-media-export-report/v1'
|
|
replayPath = (Resolve-Path -LiteralPath $ReplayPath).Path
|
|
outputBaseName = $OutputBaseName
|
|
outputWidth = $OutputWidth
|
|
outputHeight = $OutputHeight
|
|
outputFrameRate = $OutputFrameRate
|
|
replayDurationMs = $ReplayInfo.ReplayDurationMs
|
|
tailDurationMs = $TailDurationMs
|
|
renderEndFrame = $RenderEndFrame
|
|
videoPath = $VideoFile
|
|
rawVideoPath = $RawVideoFile
|
|
stillFramePath = $StillFrameFile
|
|
rawStillFramePath = $RawStillFrameFile
|
|
shareCardPath = $ShareCardPath
|
|
videoReportPath = $VideoReportPath
|
|
stillReportPath = $StillReportPath
|
|
title = $ReplayInfo.FinalTimeLabel
|
|
subtitle = $ShareSubtitle
|
|
footer = $ShareFooter
|
|
}
|
|
|
|
$AggregateReport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $AggregateReportPath -Encoding UTF8
|
|
|
|
Write-Host "Classic-cube media export succeeded."
|
|
Write-Host "Video: $VideoFile"
|
|
Write-Host "Share card: $ShareCardPath"
|