hypertwist/scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1
2026-06-13 07:04:11 +00:00

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"