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"