diff --git a/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs index c155114..cb7f762 100644 --- a/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs +++ b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs @@ -128,7 +128,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend return _options.RenderWorker.Cs2.Arguments; } - return "-ExecutionPolicy Bypass -File \"{wrapperScriptPath}\" -DemoPath \"{demoPath}\" -OutputPath \"{outputPath}\" -StartTick {startTick} -EndTick {endTick} -CameraMode \"{cameraMode}\" -Resolution \"{resolution}\" -Fps {fps} -ArtifactsDirectory \"{artifactsDirectory}\""; + return "-ExecutionPolicy Bypass -File \"{wrapperScriptPath}\" -DemoPath \"{demoPath}\" -OutputPath \"{outputPath}\" -StartTick {startTick} -EndTick {endTick} -TickRate {tickRate} -CameraMode \"{cameraMode}\" -Resolution \"{resolution}\" -Fps {fps} -ArtifactsDirectory \"{artifactsDirectory}\""; } private string ResolveWorkingDirectory() @@ -171,6 +171,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend .Replace("{outputPath}", outputPath, StringComparison.Ordinal) .Replace("{startTick}", renderJob.StartTick.ToString(), StringComparison.Ordinal) .Replace("{endTick}", renderJob.EndTick.ToString(), StringComparison.Ordinal) + .Replace("{tickRate}", renderJob.TickRate.ToString(), StringComparison.Ordinal) .Replace("{cameraMode}", renderJob.CameraMode, StringComparison.Ordinal) .Replace("{resolution}", renderJob.Resolution, StringComparison.Ordinal) .Replace("{fps}", renderJob.Fps, StringComparison.Ordinal) diff --git a/src/Fragalytics.RenderWorker/appsettings.json b/src/Fragalytics.RenderWorker/appsettings.json index e980e60..2b1c161 100644 --- a/src/Fragalytics.RenderWorker/appsettings.json +++ b/src/Fragalytics.RenderWorker/appsettings.json @@ -12,7 +12,7 @@ "RetryDelaySeconds": 30, "Cs2": { "ExecutablePath": "powershell.exe", - "Arguments": "-ExecutionPolicy Bypass -File \"{wrapperScriptPath}\" -DemoPath \"{demoPath}\" -OutputPath \"{outputPath}\" -StartTick {startTick} -EndTick {endTick} -CameraMode \"{cameraMode}\" -Resolution \"{resolution}\" -Fps {fps} -ArtifactsDirectory \"{artifactsDirectory}\"", + "Arguments": "-ExecutionPolicy Bypass -File \"{wrapperScriptPath}\" -DemoPath \"{demoPath}\" -OutputPath \"{outputPath}\" -StartTick {startTick} -EndTick {endTick} -TickRate {tickRate} -CameraMode \"{cameraMode}\" -Resolution \"{resolution}\" -Fps {fps} -ArtifactsDirectory \"{artifactsDirectory}\"", "WorkingDirectory": "", "TimeoutSeconds": 900, "ArtifactsDirectory": "data/render-artifacts", diff --git a/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 b/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 index 44cced6..e359a6e 100644 --- a/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 +++ b/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 @@ -11,6 +11,9 @@ param( [Parameter(Mandatory = $true)] [int]$EndTick, + [Parameter(Mandatory = $false)] + [int]$TickRate = 64, + [Parameter(Mandatory = $true)] [string]$CameraMode, @@ -34,67 +37,380 @@ param( ) $ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Resolve-OptionalPath { + param([string]$PathValue) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return "" + } + + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PathValue) +} + +function Write-Log { + param( + [string]$Message, + [string]$LogPath + ) + + $timestamp = [DateTime]::UtcNow.ToString("O") + Add-Content -LiteralPath $LogPath -Value "[$timestamp] $Message" +} + +function Invoke-ExternalCommand { + param( + [string]$FilePath, + [string]$Arguments, + [string]$WorkingDirectory, + [string]$LogPath, + [switch]$Optional + ) + + if ([string]::IsNullOrWhiteSpace($FilePath)) { + if ($Optional) { + Write-Log "Skipping optional command because no executable was provided." $LogPath + return + } + + throw "Command executable path was not provided." + } + + $workingDirectoryToUse = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { + Split-Path -Parent $FilePath + } + else { + $WorkingDirectory + } + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + + try { + Write-Log "Running: $FilePath $Arguments" $LogPath + $process = Start-Process ` + -FilePath $FilePath ` + -ArgumentList $Arguments ` + -WorkingDirectory $workingDirectoryToUse ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru ` + -Wait + + foreach ($line in Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue) { + Write-Log "$line" $LogPath + } + + foreach ($line in Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue) { + Write-Log "$line" $LogPath + } + + if ($process.ExitCode -ne 0 -and -not $Optional) { + throw "Command failed with exit code $($process.ExitCode): $FilePath $Arguments" + } + } + finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-TemplateCommand { + param( + [string]$Template, + [hashtable]$Context, + [string]$LogPath, + [switch]$Optional + ) + + if ([string]::IsNullOrWhiteSpace($Template)) { + if ($Optional) { + Write-Log "Skipping optional templated command because no template was configured." $LogPath + return + } + + throw "Required templated command was not configured." + } + + $expanded = $Template + foreach ($key in $Context.Keys) { + $expanded = $expanded.Replace("{$key}", [string]$Context[$key], [System.StringComparison]::Ordinal) + } + + Write-Log "Executing templated PowerShell command: $expanded" $LogPath + Invoke-Expression $expanded 2>&1 | ForEach-Object { + Write-Log "$_" $LogPath + } + + if ($LASTEXITCODE -ne 0 -and -not $Optional) { + throw "Templated command failed with exit code $LASTEXITCODE." + } +} + +function Parse-Resolution { + param([string]$ResolutionValue) + + if ($ResolutionValue -notmatch '^(?\d+)x(?\d+)$') { + throw "Resolution must use WIDTHxHEIGHT format, got '$ResolutionValue'." + } + + return @{ + Width = [int]$Matches.width + Height = [int]$Matches.height + } +} + +function Get-EnvDouble { + param( + [string]$Name, + [double]$Fallback + ) + + if ([string]::IsNullOrWhiteSpace((Get-Item "env:$Name" -ErrorAction SilentlyContinue).Value)) { + return $Fallback + } + + $value = (Get-Item "env:$Name").Value + $parsed = 0.0 + if ([double]::TryParse($value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { + return $parsed + } + + return $Fallback +} + +function Write-CfgFile { + param( + [string]$CfgPath, + [string]$DemoAbsolutePath, + [int]$StartTickValue, + [int]$PrerollTicks, + [string]$SafeCameraMode + ) + + $demoForCfg = $DemoAbsolutePath.Replace('\', '/') + $gotoTick = [Math]::Max(0, $StartTickValue - $PrerollTicks) + + @( + "echo ""[Fragalytics] render bootstrap started""" + "sv_cheats 1" + "mirv_deathmsg lifetime 0" + "spec_show_xray 0" + "demo_pause" + "playdemo ""$demoForCfg""" + "demo_goto $gotoTick" + "spec_mode 4" + "echo ""[Fragalytics] camera mode requested: $SafeCameraMode""" + "demo_resume" + ) | Set-Content -LiteralPath $CfgPath -Encoding ASCII +} if (-not (Test-Path -LiteralPath $DemoPath)) { throw "Demo file was not found: $DemoPath" } -$outputDirectory = Split-Path -Parent $OutputPath +if ($EndTick -le $StartTick) { + throw "EndTick must be greater than StartTick." +} + +if ($TickRate -le 0) { + throw "TickRate must be greater than zero." +} + +if ($Fps -le 0) { + throw "Fps must be greater than zero." +} + +$resolutionParts = Parse-Resolution $Resolution.ToLowerInvariant() +$demoAbsolutePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DemoPath) +$outputAbsolutePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) + +$outputDirectory = Split-Path -Parent $outputAbsolutePath if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null } -if (-not [string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { - New-Item -ItemType Directory -Force -Path $ArtifactsDirectory | Out-Null -} - -$manifestPath = if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { - [System.IO.Path]::ChangeExtension($OutputPath, ".render.json") +$artifactsAbsolutePath = if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { + Split-Path -Parent $outputAbsolutePath } else { - Join-Path $ArtifactsDirectory "render-manifest.json" + $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ArtifactsDirectory) +} +New-Item -ItemType Directory -Force -Path $artifactsAbsolutePath | Out-Null + +$logPath = Join-Path $artifactsAbsolutePath "render-wrapper.log" +$manifestPath = Join-Path $artifactsAbsolutePath "render-manifest.json" +$cfgPath = Join-Path $artifactsAbsolutePath "fragalytics-render.cfg" +$copyCfgPath = Join-Path $artifactsAbsolutePath "copy-render-cfg.cmd" +$launchHintPath = Join-Path $artifactsAbsolutePath "launch-cs2.cmd" +$steamPath = Resolve-OptionalPath $SteamPath +$cs2Path = Resolve-OptionalPath $Cs2Path +$obsCliPath = Resolve-OptionalPath $ObsCliPath + +Set-Content -LiteralPath $logPath -Value "" +Write-Log "Preparing scripted CS2 render workflow." $logPath + +$tickWindow = $EndTick - $StartTick +$clipDurationSeconds = [Math]::Ceiling([double]$tickWindow / [double]$TickRate) +$prerollTicks = [Math]::Min([Math]::Max([int]($TickRate * 2), 64), $StartTick) +$postrollSeconds = Get-EnvDouble -Name "FRAGALYTICS_RENDER_POSTROLL_SECONDS" -Fallback 1.0 +$warmupSeconds = Get-EnvDouble -Name "FRAGALYTICS_RENDER_WARMUP_SECONDS" -Fallback 8.0 +$captureLeadInSeconds = Get-EnvDouble -Name "FRAGALYTICS_RENDER_CAPTURE_LEAD_IN_SECONDS" -Fallback 1.0 +$shutdownGraceSeconds = Get-EnvDouble -Name "FRAGALYTICS_RENDER_SHUTDOWN_GRACE_SECONDS" -Fallback 5.0 +$captureDurationSeconds = [Math]::Max(1.0, $clipDurationSeconds + $postrollSeconds) +$outputExtension = [System.IO.Path]::GetExtension($outputAbsolutePath) +$captureStartTemplate = $env:FRAGALYTICS_RENDER_CAPTURE_START_COMMAND +$captureStopTemplate = $env:FRAGALYTICS_RENDER_CAPTURE_STOP_COMMAND +$prepareTemplate = $env:FRAGALYTICS_RENDER_PREPARE_COMMAND +$postProcessTemplate = $env:FRAGALYTICS_RENDER_POSTPROCESS_COMMAND +$launchArgsTemplate = if ([string]::IsNullOrWhiteSpace($env:FRAGALYTICS_CS2_LAUNCH_ARGS)) { + '-allow_third_party_software -insecure -novid -console -windowed -w {width} -h {height} +exec "{cfgPath}"' +} +else { + $env:FRAGALYTICS_CS2_LAUNCH_ARGS +} + +Write-CfgFile -CfgPath $cfgPath -DemoAbsolutePath $demoAbsolutePath -StartTickValue $StartTick -PrerollTicks $prerollTicks -SafeCameraMode $CameraMode + +$launchContext = @{ + artifactsDirectory = $artifactsAbsolutePath + cfgPath = $cfgPath + cs2Path = $cs2Path + demoPath = $demoAbsolutePath + endTick = $EndTick + fps = $Fps + height = $resolutionParts.Height + logPath = $logPath + obsCliPath = $obsCliPath + outputExtension = $outputExtension + outputPath = $outputAbsolutePath + resolution = $Resolution.ToLowerInvariant() + startTick = $StartTick + steamPath = $steamPath + tickRate = $TickRate + warmupSeconds = $warmupSeconds + captureLeadInSeconds = $captureLeadInSeconds + captureDurationSeconds = $captureDurationSeconds + shutdownGraceSeconds = $shutdownGraceSeconds + width = $resolutionParts.Width +} + +$expandedLaunchArgs = $launchArgsTemplate +foreach ($key in $launchContext.Keys) { + $expandedLaunchArgs = $expandedLaunchArgs.Replace("{$key}", [string]$launchContext[$key], [System.StringComparison]::Ordinal) } $manifest = [ordered]@{ - demoPath = $DemoPath - outputPath = $OutputPath + demoPath = $demoAbsolutePath + outputPath = $outputAbsolutePath startTick = $StartTick endTick = $EndTick + tickRate = $TickRate cameraMode = $CameraMode - resolution = $Resolution + resolution = $Resolution.ToLowerInvariant() fps = $Fps - cs2Path = $Cs2Path - steamPath = $SteamPath - obsCliPath = $ObsCliPath + clipDurationSeconds = $clipDurationSeconds + captureDurationSeconds = $captureDurationSeconds + prerollTicks = $prerollTicks + warmupSeconds = $warmupSeconds + captureLeadInSeconds = $captureLeadInSeconds + shutdownGraceSeconds = $shutdownGraceSeconds + cs2Path = $cs2Path + steamPath = $steamPath + obsCliPath = $obsCliPath + launchArguments = $expandedLaunchArgs + cfgPath = $cfgPath + prepareCommand = $prepareTemplate + captureStartCommand = $captureStartTemplate + captureStopCommand = $captureStopTemplate + postProcessCommand = $postProcessTemplate createdAtUtc = [DateTime]::UtcNow.ToString("O") } +$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 -$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 +@( + "@echo off" + "copy /Y ""$cfgPath"" ""%USERPROFILE%\game\csgo\cfg\fragalytics-render.cfg""" +) | Set-Content -LiteralPath $copyCfgPath -Encoding ASCII -if ([string]::IsNullOrWhiteSpace($Cs2Path)) { - throw "FRAGALYTICS_CS2_PATH or -Cs2Path is not configured. Review $manifestPath and update render-cs2-clip.ps1 with your capture workflow." +@( + "@echo off" + """$cs2Path"" $expandedLaunchArgs" +) | Set-Content -LiteralPath $launchHintPath -Encoding ASCII + +if ([string]::IsNullOrWhiteSpace($cs2Path)) { + throw "FRAGALYTICS_CS2_PATH or -Cs2Path is not configured. Review $manifestPath." } -if (-not (Test-Path -LiteralPath $Cs2Path)) { - throw "Configured CS2 executable was not found: $Cs2Path" +if (-not (Test-Path -LiteralPath $cs2Path)) { + throw "Configured CS2 executable was not found: $cs2Path" } -throw @" -CS2 scripted wrapper was invoked successfully, but the actual capture workflow is not implemented yet. +if (-not [string]::IsNullOrWhiteSpace($prepareTemplate)) { + Invoke-TemplateCommand -Template $prepareTemplate -Context $launchContext -LogPath $logPath +} -Edit: - $PSScriptRoot\render-cs2-clip.ps1 +$captureStarted = $false +$cs2Process = $null +try { + Write-Log "Starting CS2 process." $logPath + $cs2Process = Start-Process -FilePath $cs2Path -ArgumentList $expandedLaunchArgs -WorkingDirectory (Split-Path -Parent $cs2Path) -PassThru + Write-Log "CS2 process id: $($cs2Process.Id)" $logPath -Inputs captured in: + Start-Sleep -Seconds $warmupSeconds + + if (-not [string]::IsNullOrWhiteSpace($captureStartTemplate)) { + Invoke-TemplateCommand -Template $captureStartTemplate -Context $launchContext -LogPath $logPath + $captureStarted = $true + } + elseif (-not [string]::IsNullOrWhiteSpace($obsCliPath)) { + Invoke-ExternalCommand -FilePath $obsCliPath -Arguments "recording start" -WorkingDirectory $artifactsAbsolutePath -LogPath $logPath + $captureStarted = $true + } + else { + Write-Log "No capture start command was configured. The wrapper will launch CS2 and wait, but capture must be handled externally." $logPath + } + + Start-Sleep -Seconds $captureLeadInSeconds + Write-Log "Waiting $captureDurationSeconds seconds for the clip window to play." $logPath + Start-Sleep -Seconds $captureDurationSeconds + + if ($captureStarted) { + if (-not [string]::IsNullOrWhiteSpace($captureStopTemplate)) { + Invoke-TemplateCommand -Template $captureStopTemplate -Context $launchContext -LogPath $logPath -Optional + } + elseif (-not [string]::IsNullOrWhiteSpace($obsCliPath)) { + Invoke-ExternalCommand -FilePath $obsCliPath -Arguments "recording stop" -WorkingDirectory $artifactsAbsolutePath -LogPath $logPath -Optional + } + } + + if (-not [string]::IsNullOrWhiteSpace($postProcessTemplate)) { + Invoke-TemplateCommand -Template $postProcessTemplate -Context $launchContext -LogPath $logPath + } + + if (-not (Test-Path -LiteralPath $outputAbsolutePath)) { + throw @" +No output file was produced at: + $outputAbsolutePath + +Review: $manifestPath + $logPath + $cfgPath -Expected output: - $OutputPath - -Recommended next automation step: - 1. Launch CS2 with your preferred startup flags - 2. Load the demo at $DemoPath - 3. Seek near tick $StartTick and capture until $EndTick - 4. Encode to $OutputPath - 5. Exit 0 only if the output file exists +Common fixes: + - set FRAGALYTICS_RENDER_CAPTURE_START_COMMAND and FRAGALYTICS_RENDER_CAPTURE_STOP_COMMAND + - or point FRAGALYTICS_OBS_CLI_PATH at obs-cli + - update FRAGALYTICS_CS2_LAUNCH_ARGS so CS2 loads the generated cfg "@ + } + + Write-Log "Render completed successfully." $logPath +} +finally { + if ($cs2Process -and -not $cs2Process.HasExited) { + Write-Log "Stopping CS2 process." $logPath + Stop-Process -Id $cs2Process.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds $shutdownGraceSeconds + } +} diff --git a/src/Fragalytics.Web/Models/AppState.cs b/src/Fragalytics.Web/Models/AppState.cs index 3c672b1..7a4bb7b 100644 --- a/src/Fragalytics.Web/Models/AppState.cs +++ b/src/Fragalytics.Web/Models/AppState.cs @@ -190,6 +190,7 @@ public sealed class RenderJobContract public const string DefaultOutputFormat = "mp4"; public const string DefaultResolution = "1920x1080"; public const string DefaultFps = "60"; + public const int DefaultTickRate = 64; public string Version { get; set; } = CurrentVersion; public int DemoId { get; set; } @@ -198,13 +199,24 @@ public sealed class RenderJobContract public int SourceEventId { get; set; } public int StartTick { get; set; } public int EndTick { get; set; } + public int TickRate { get; set; } = DefaultTickRate; public string CameraMode { get; set; } = DefaultCameraMode; public string OutputFormat { get; set; } = DefaultOutputFormat; public string Resolution { get; set; } = DefaultResolution; public string Fps { get; set; } = DefaultFps; + public void Normalize() + { + Version = string.IsNullOrWhiteSpace(Version) ? CurrentVersion : Version.Trim(); + CameraMode = NormalizeString(CameraMode, DefaultCameraMode); + OutputFormat = NormalizeString(OutputFormat, DefaultOutputFormat).ToLowerInvariant(); + Resolution = NormalizeString(Resolution, DefaultResolution).ToLowerInvariant(); + Fps = NormalizePositiveNumber(Fps, DefaultFps); + } + public Dictionary ToPayload() { + Normalize(); return new Dictionary { ["version"] = Version, @@ -214,6 +226,7 @@ public sealed class RenderJobContract ["sourceEventId"] = SourceEventId.ToString(), ["startTick"] = StartTick.ToString(), ["endTick"] = EndTick.ToString(), + ["tickRate"] = TickRate.ToString(), ["cameraMode"] = CameraMode, ["outputFormat"] = OutputFormat, ["resolution"] = Resolution, @@ -223,7 +236,7 @@ public sealed class RenderJobContract public static RenderJobContract FromPayload(IReadOnlyDictionary payload) { - return new RenderJobContract + var contract = new RenderJobContract { Version = ReadString(payload, "version", CurrentVersion), DemoId = ReadInt(payload, "demoId"), @@ -232,11 +245,15 @@ public sealed class RenderJobContract SourceEventId = ReadInt(payload, "sourceEventId"), StartTick = ReadInt(payload, "startTick"), EndTick = ReadInt(payload, "endTick"), + TickRate = ReadInt(payload, "tickRate", DefaultTickRate), CameraMode = ReadString(payload, "cameraMode", DefaultCameraMode), OutputFormat = ReadString(payload, "outputFormat", DefaultOutputFormat), Resolution = ReadString(payload, "resolution", DefaultResolution), Fps = ReadString(payload, "fps", DefaultFps) }; + + contract.Normalize(); + return contract; } public string GetSuggestedFileExtension() @@ -244,15 +261,27 @@ public sealed class RenderJobContract return ClipMediaFormats.GetSuggestedFileExtension(OutputFormat); } - private static int ReadInt(IReadOnlyDictionary payload, string key) + private static int ReadInt(IReadOnlyDictionary payload, string key, int fallback = 0) { - return payload.TryGetValue(key, out var raw) && int.TryParse(raw, out var value) ? value : 0; + return payload.TryGetValue(key, out var raw) && int.TryParse(raw, out var value) ? value : fallback; } private static string ReadString(IReadOnlyDictionary payload, string key, string fallback) { return payload.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; } + + private static string NormalizeString(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private static string NormalizePositiveNumber(string? value, string fallback) + { + return int.TryParse(value, out var parsed) && parsed > 0 + ? parsed.ToString() + : fallback; + } } public sealed class ClipAssetRecord diff --git a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml index 0581ddb..1c376bf 100644 --- a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml +++ b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml @@ -50,8 +50,36 @@ @row.ClipJob.AttemptCount
- Payload - @string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}")) + Highlight + #@row.RenderJob.HighlightId +
+
+ Demo / Match + #@row.RenderJob.DemoId / #@row.RenderJob.MatchId +
+
+ Camera + @row.RenderJob.CameraMode +
+
+ Output + @row.RenderJob.OutputFormat +
+
+ Resolution + @row.RenderJob.Resolution +
+
+ FPS + @row.RenderJob.Fps +
+
+ Tick Rate + @row.RenderJob.TickRate +
+
+ Contract + v@row.RenderJob.Version
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage)) { diff --git a/src/Fragalytics.Web/Services/QueryService.cs b/src/Fragalytics.Web/Services/QueryService.cs index 4f5a1b1..91f7fe8 100644 --- a/src/Fragalytics.Web/Services/QueryService.cs +++ b/src/Fragalytics.Web/Services/QueryService.cs @@ -80,6 +80,7 @@ public sealed class QueryService .Select(job => new ClipJobWithHighlightViewModel { ClipJob = job, + RenderJob = RenderJobContract.FromPayload(job.Payload), Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId), ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId) }) @@ -204,6 +205,7 @@ public sealed class HighlightDetailsRow public sealed class ClipJobWithHighlightViewModel { public ClipJobRecord ClipJob { get; set; } = new(); + public RenderJobContract RenderJob { get; set; } = new(); public HighlightRecord? Highlight { get; set; } public ClipAssetRecord? ClipAsset { get; set; } } diff --git a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs index bfafbc0..418540d 100644 --- a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs +++ b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs @@ -106,7 +106,8 @@ public sealed class MatchScoringService HighlightId = highlight.Id, SourceEventId = candidate.Id, StartTick = highlight.StartTick, - EndTick = highlight.EndTick + EndTick = highlight.EndTick, + TickRate = match.TickRate.GetValueOrDefault(RenderJobContract.DefaultTickRate) }; var clipJob = new ClipJobRecord diff --git a/tests/Fragalytics.Web.Tests/Program.cs b/tests/Fragalytics.Web.Tests/Program.cs index 0bba0b9..1c8d164 100644 --- a/tests/Fragalytics.Web.Tests/Program.cs +++ b/tests/Fragalytics.Web.Tests/Program.cs @@ -214,11 +214,12 @@ static async Task RunFlowTestAsync() Assert(state.ClipJobs[0].Payload.TryGetValue("version", out var version) && version == RenderJobContract.CurrentVersion, "Expected versioned render payload."); Assert(state.ClipJobs[0].Payload.TryGetValue("demoId", out var demoIdPayload) && demoIdPayload == "1", "Expected demo id in render payload."); Assert(state.ClipJobs[0].Payload.ContainsKey("cameraMode"), "Expected render payload defaults on clip job."); - Assert(state.ClipJobs[0].Payload.Keys.All(key => key is "version" or "demoId" or "matchId" or "highlightId" or "sourceEventId" or "startTick" or "endTick" or "cameraMode" or "outputFormat" or "resolution" or "fps"), "Expected payload to contain only render contract fields."); + Assert(state.ClipJobs[0].Payload.Keys.All(key => key is "version" or "demoId" or "matchId" or "highlightId" or "sourceEventId" or "startTick" or "endTick" or "tickRate" or "cameraMode" or "outputFormat" or "resolution" or "fps"), "Expected payload to contain only render contract fields."); var renderJob = RenderJobContract.FromPayload(state.ClipJobs[0].Payload); Assert(renderJob.MatchId == state.Matches[0].Id, "Expected match id in typed render payload."); Assert(renderJob.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format."); + Assert(renderJob.TickRate == state.Matches[0].TickRate, "Expected match tick rate in typed render payload."); await ingest.RescoreAllAsync(CancellationToken.None); state = await store.GetSnapshotAsync(); @@ -317,10 +318,33 @@ static void RunRenderJobContractDefaultsTest() Assert(contract.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format."); Assert(contract.Resolution == RenderJobContract.DefaultResolution, "Expected default resolution."); Assert(contract.Fps == RenderJobContract.DefaultFps, "Expected default fps."); + Assert(contract.TickRate == RenderJobContract.DefaultTickRate, "Expected default tick rate."); Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.Mp4Extension, "Expected mp4 extension for default output format."); contract.OutputFormat = "webm"; Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.WebMExtension, "Expected webm extension override."); + + var messyPayload = new Dictionary + { + ["demoId"] = "9", + ["matchId"] = "17", + ["highlightId"] = "23", + ["sourceEventId"] = "42", + ["startTick"] = "1500", + ["endTick"] = "1800", + ["tickRate"] = "128", + ["cameraMode"] = " killer_pov ", + ["outputFormat"] = " WEBM ", + ["resolution"] = " 1280X720 ", + ["fps"] = "0" + }; + + var normalized = RenderJobContract.FromPayload(messyPayload); + Assert(normalized.CameraMode == RenderJobContract.DefaultCameraMode, "Expected camera mode to be trimmed."); + Assert(normalized.OutputFormat == "webm", "Expected output format to be normalized."); + Assert(normalized.Resolution == "1280x720", "Expected resolution to be normalized."); + Assert(normalized.Fps == RenderJobContract.DefaultFps, "Expected invalid fps to fall back to the default."); + Assert(normalized.TickRate == 128, "Expected tick rate to survive normalization."); } static async Task RunRescoreReplacementTestAsync()