This commit is contained in:
Zeeshaun
2026-04-23 02:23:23 +00:00
parent 45354397f1
commit a7afe2285b
8 changed files with 443 additions and 42 deletions
@@ -128,7 +128,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend
return _options.RenderWorker.Cs2.Arguments; 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() private string ResolveWorkingDirectory()
@@ -171,6 +171,7 @@ public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend
.Replace("{outputPath}", outputPath, StringComparison.Ordinal) .Replace("{outputPath}", outputPath, StringComparison.Ordinal)
.Replace("{startTick}", renderJob.StartTick.ToString(), StringComparison.Ordinal) .Replace("{startTick}", renderJob.StartTick.ToString(), StringComparison.Ordinal)
.Replace("{endTick}", renderJob.EndTick.ToString(), StringComparison.Ordinal) .Replace("{endTick}", renderJob.EndTick.ToString(), StringComparison.Ordinal)
.Replace("{tickRate}", renderJob.TickRate.ToString(), StringComparison.Ordinal)
.Replace("{cameraMode}", renderJob.CameraMode, StringComparison.Ordinal) .Replace("{cameraMode}", renderJob.CameraMode, StringComparison.Ordinal)
.Replace("{resolution}", renderJob.Resolution, StringComparison.Ordinal) .Replace("{resolution}", renderJob.Resolution, StringComparison.Ordinal)
.Replace("{fps}", renderJob.Fps, StringComparison.Ordinal) .Replace("{fps}", renderJob.Fps, StringComparison.Ordinal)
@@ -12,7 +12,7 @@
"RetryDelaySeconds": 30, "RetryDelaySeconds": 30,
"Cs2": { "Cs2": {
"ExecutablePath": "powershell.exe", "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": "", "WorkingDirectory": "",
"TimeoutSeconds": 900, "TimeoutSeconds": 900,
"ArtifactsDirectory": "data/render-artifacts", "ArtifactsDirectory": "data/render-artifacts",
@@ -11,6 +11,9 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[int]$EndTick, [int]$EndTick,
[Parameter(Mandatory = $false)]
[int]$TickRate = 64,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$CameraMode, [string]$CameraMode,
@@ -34,67 +37,380 @@ param(
) )
$ErrorActionPreference = "Stop" $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 '^(?<width>\d+)x(?<height>\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)) { if (-not (Test-Path -LiteralPath $DemoPath)) {
throw "Demo file was not found: $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)) { if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
} }
if (-not [string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { $artifactsAbsolutePath = if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) {
New-Item -ItemType Directory -Force -Path $ArtifactsDirectory | Out-Null Split-Path -Parent $outputAbsolutePath
}
$manifestPath = if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) {
[System.IO.Path]::ChangeExtension($OutputPath, ".render.json")
} }
else { 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]@{ $manifest = [ordered]@{
demoPath = $DemoPath demoPath = $demoAbsolutePath
outputPath = $OutputPath outputPath = $outputAbsolutePath
startTick = $StartTick startTick = $StartTick
endTick = $EndTick endTick = $EndTick
tickRate = $TickRate
cameraMode = $CameraMode cameraMode = $CameraMode
resolution = $Resolution resolution = $Resolution.ToLowerInvariant()
fps = $Fps fps = $Fps
cs2Path = $Cs2Path clipDurationSeconds = $clipDurationSeconds
steamPath = $SteamPath captureDurationSeconds = $captureDurationSeconds
obsCliPath = $ObsCliPath 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") 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)) { if (-not (Test-Path -LiteralPath $cs2Path)) {
throw "Configured CS2 executable was not found: $Cs2Path" throw "Configured CS2 executable was not found: $cs2Path"
} }
throw @" if (-not [string]::IsNullOrWhiteSpace($prepareTemplate)) {
CS2 scripted wrapper was invoked successfully, but the actual capture workflow is not implemented yet. Invoke-TemplateCommand -Template $prepareTemplate -Context $launchContext -LogPath $logPath
}
Edit: $captureStarted = $false
$PSScriptRoot\render-cs2-clip.ps1 $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 $manifestPath
$logPath
$cfgPath
Expected output: Common fixes:
$OutputPath - set FRAGALYTICS_RENDER_CAPTURE_START_COMMAND and FRAGALYTICS_RENDER_CAPTURE_STOP_COMMAND
- or point FRAGALYTICS_OBS_CLI_PATH at obs-cli
Recommended next automation step: - update FRAGALYTICS_CS2_LAUNCH_ARGS so CS2 loads the generated cfg
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
"@ "@
}
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
}
}
+32 -3
View File
@@ -190,6 +190,7 @@ public sealed class RenderJobContract
public const string DefaultOutputFormat = "mp4"; public const string DefaultOutputFormat = "mp4";
public const string DefaultResolution = "1920x1080"; public const string DefaultResolution = "1920x1080";
public const string DefaultFps = "60"; public const string DefaultFps = "60";
public const int DefaultTickRate = 64;
public string Version { get; set; } = CurrentVersion; public string Version { get; set; } = CurrentVersion;
public int DemoId { get; set; } public int DemoId { get; set; }
@@ -198,13 +199,24 @@ public sealed class RenderJobContract
public int SourceEventId { get; set; } public int SourceEventId { get; set; }
public int StartTick { get; set; } public int StartTick { get; set; }
public int EndTick { get; set; } public int EndTick { get; set; }
public int TickRate { get; set; } = DefaultTickRate;
public string CameraMode { get; set; } = DefaultCameraMode; public string CameraMode { get; set; } = DefaultCameraMode;
public string OutputFormat { get; set; } = DefaultOutputFormat; public string OutputFormat { get; set; } = DefaultOutputFormat;
public string Resolution { get; set; } = DefaultResolution; public string Resolution { get; set; } = DefaultResolution;
public string Fps { get; set; } = DefaultFps; 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<string, string> ToPayload() public Dictionary<string, string> ToPayload()
{ {
Normalize();
return new Dictionary<string, string> return new Dictionary<string, string>
{ {
["version"] = Version, ["version"] = Version,
@@ -214,6 +226,7 @@ public sealed class RenderJobContract
["sourceEventId"] = SourceEventId.ToString(), ["sourceEventId"] = SourceEventId.ToString(),
["startTick"] = StartTick.ToString(), ["startTick"] = StartTick.ToString(),
["endTick"] = EndTick.ToString(), ["endTick"] = EndTick.ToString(),
["tickRate"] = TickRate.ToString(),
["cameraMode"] = CameraMode, ["cameraMode"] = CameraMode,
["outputFormat"] = OutputFormat, ["outputFormat"] = OutputFormat,
["resolution"] = Resolution, ["resolution"] = Resolution,
@@ -223,7 +236,7 @@ public sealed class RenderJobContract
public static RenderJobContract FromPayload(IReadOnlyDictionary<string, string> payload) public static RenderJobContract FromPayload(IReadOnlyDictionary<string, string> payload)
{ {
return new RenderJobContract var contract = new RenderJobContract
{ {
Version = ReadString(payload, "version", CurrentVersion), Version = ReadString(payload, "version", CurrentVersion),
DemoId = ReadInt(payload, "demoId"), DemoId = ReadInt(payload, "demoId"),
@@ -232,11 +245,15 @@ public sealed class RenderJobContract
SourceEventId = ReadInt(payload, "sourceEventId"), SourceEventId = ReadInt(payload, "sourceEventId"),
StartTick = ReadInt(payload, "startTick"), StartTick = ReadInt(payload, "startTick"),
EndTick = ReadInt(payload, "endTick"), EndTick = ReadInt(payload, "endTick"),
TickRate = ReadInt(payload, "tickRate", DefaultTickRate),
CameraMode = ReadString(payload, "cameraMode", DefaultCameraMode), CameraMode = ReadString(payload, "cameraMode", DefaultCameraMode),
OutputFormat = ReadString(payload, "outputFormat", DefaultOutputFormat), OutputFormat = ReadString(payload, "outputFormat", DefaultOutputFormat),
Resolution = ReadString(payload, "resolution", DefaultResolution), Resolution = ReadString(payload, "resolution", DefaultResolution),
Fps = ReadString(payload, "fps", DefaultFps) Fps = ReadString(payload, "fps", DefaultFps)
}; };
contract.Normalize();
return contract;
} }
public string GetSuggestedFileExtension() public string GetSuggestedFileExtension()
@@ -244,15 +261,27 @@ public sealed class RenderJobContract
return ClipMediaFormats.GetSuggestedFileExtension(OutputFormat); return ClipMediaFormats.GetSuggestedFileExtension(OutputFormat);
} }
private static int ReadInt(IReadOnlyDictionary<string, string> payload, string key) private static int ReadInt(IReadOnlyDictionary<string, string> 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<string, string> payload, string key, string fallback) private static string ReadString(IReadOnlyDictionary<string, string> payload, string key, string fallback)
{ {
return payload.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : 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 public sealed class ClipAssetRecord
@@ -50,8 +50,36 @@
<strong>@row.ClipJob.AttemptCount</strong> <strong>@row.ClipJob.AttemptCount</strong>
</div> </div>
<div class="explanation-row"> <div class="explanation-row">
<span>Payload</span> <span>Highlight</span>
<code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code> <strong>#@row.RenderJob.HighlightId</strong>
</div>
<div class="explanation-row">
<span>Demo / Match</span>
<strong>#@row.RenderJob.DemoId / #@row.RenderJob.MatchId</strong>
</div>
<div class="explanation-row">
<span>Camera</span>
<code>@row.RenderJob.CameraMode</code>
</div>
<div class="explanation-row">
<span>Output</span>
<code>@row.RenderJob.OutputFormat</code>
</div>
<div class="explanation-row">
<span>Resolution</span>
<code>@row.RenderJob.Resolution</code>
</div>
<div class="explanation-row">
<span>FPS</span>
<code>@row.RenderJob.Fps</code>
</div>
<div class="explanation-row">
<span>Tick Rate</span>
<code>@row.RenderJob.TickRate</code>
</div>
<div class="explanation-row">
<span>Contract</span>
<code>[email protected]</code>
</div> </div>
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage)) @if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
{ {
@@ -80,6 +80,7 @@ public sealed class QueryService
.Select(job => new ClipJobWithHighlightViewModel .Select(job => new ClipJobWithHighlightViewModel
{ {
ClipJob = job, ClipJob = job,
RenderJob = RenderJobContract.FromPayload(job.Payload),
Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId), Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId),
ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId) ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId)
}) })
@@ -204,6 +205,7 @@ public sealed class HighlightDetailsRow
public sealed class ClipJobWithHighlightViewModel public sealed class ClipJobWithHighlightViewModel
{ {
public ClipJobRecord ClipJob { get; set; } = new(); public ClipJobRecord ClipJob { get; set; } = new();
public RenderJobContract RenderJob { get; set; } = new();
public HighlightRecord? Highlight { get; set; } public HighlightRecord? Highlight { get; set; }
public ClipAssetRecord? ClipAsset { get; set; } public ClipAssetRecord? ClipAsset { get; set; }
} }
@@ -106,7 +106,8 @@ public sealed class MatchScoringService
HighlightId = highlight.Id, HighlightId = highlight.Id,
SourceEventId = candidate.Id, SourceEventId = candidate.Id,
StartTick = highlight.StartTick, StartTick = highlight.StartTick,
EndTick = highlight.EndTick EndTick = highlight.EndTick,
TickRate = match.TickRate.GetValueOrDefault(RenderJobContract.DefaultTickRate)
}; };
var clipJob = new ClipJobRecord var clipJob = new ClipJobRecord
+25 -1
View File
@@ -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("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.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.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); var renderJob = RenderJobContract.FromPayload(state.ClipJobs[0].Payload);
Assert(renderJob.MatchId == state.Matches[0].Id, "Expected match id in typed render 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.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); await ingest.RescoreAllAsync(CancellationToken.None);
state = await store.GetSnapshotAsync(); state = await store.GetSnapshotAsync();
@@ -317,10 +318,33 @@ static void RunRenderJobContractDefaultsTest()
Assert(contract.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format."); Assert(contract.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format.");
Assert(contract.Resolution == RenderJobContract.DefaultResolution, "Expected default resolution."); Assert(contract.Resolution == RenderJobContract.DefaultResolution, "Expected default resolution.");
Assert(contract.Fps == RenderJobContract.DefaultFps, "Expected default fps."); 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."); Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.Mp4Extension, "Expected mp4 extension for default output format.");
contract.OutputFormat = "webm"; contract.OutputFormat = "webm";
Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.WebMExtension, "Expected webm extension override."); Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.WebMExtension, "Expected webm extension override.");
var messyPayload = new Dictionary<string, string>
{
["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() static async Task RunRescoreReplacementTestAsync()