From c52bd0c50ce87795f1c507e5550ca8c88d8a4efa Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 21 Apr 2026 08:10:51 -0500 Subject: [PATCH] Adding features --- Directory.Build.props | 13 + NuGet.config | 3 + docs/rendering-from-cs2-demo.md | 21 +- src/Fragalytics.RenderWorker/Program.cs | 16 +- .../Services/Cs2ScriptedRenderBackend.cs | 300 ++++++++++++++++++ .../Services/RenderCoordinator.cs | 24 +- src/Fragalytics.RenderWorker/appsettings.json | 14 +- .../scripts/render-cs2-clip.ps1 | 100 ++++++ src/Fragalytics.Web/FragalyticsOptions.cs | 14 + .../Infrastructure/AppPathResolver.cs | 161 ++++++++++ src/Fragalytics.Web/Models/AppState.cs | 81 +++++ .../Pages/ClipJobs/Index.cshtml | 15 +- .../Pages/ClipJobs/Index.cshtml.cs | 1 + .../Pages/Demos/Details.cshtml | 8 +- .../Pages/Demos/Details.cshtml.cs | 11 +- src/Fragalytics.Web/Pages/Index.cshtml | 19 +- src/Fragalytics.Web/Pages/Index.cshtml.cs | 29 +- src/Fragalytics.Web/Program.cs | 28 +- .../Services/ClipMediaService.cs | 1 - .../Services/DemoIngestService.cs | 36 +++ .../Services/Render/ClipJobRetryPolicy.cs | 38 +++ .../Render/NoOpRenderWorkerAdapter.cs | 1 - .../Services/Scoring/MatchScoringService.cs | 32 +- src/Fragalytics.Web/appsettings.json | 12 +- tests/Fragalytics.Web.Tests/Program.cs | 135 +++++++- 25 files changed, 1059 insertions(+), 54 deletions(-) create mode 100644 Directory.Build.props create mode 100644 src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs create mode 100644 src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 create mode 100644 src/Fragalytics.Web/Infrastructure/AppPathResolver.cs create mode 100644 src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9678f29 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ + + + windows + unix + + + + $(MSBuildThisFileDirectory).artifacts/ + $(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/ + $(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/ + $(DefaultItemExcludes);$(RepoArtifactsPath.Replace('\', '/'))** + + diff --git a/NuGet.config b/NuGet.config index a3f3aea..eed796b 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,5 +1,8 @@ + + + diff --git a/docs/rendering-from-cs2-demo.md b/docs/rendering-from-cs2-demo.md index 222eb2e..b741cdf 100644 --- a/docs/rendering-from-cs2-demo.md +++ b/docs/rendering-from-cs2-demo.md @@ -11,15 +11,32 @@ What already works: - Highlight scoring - Clip job creation with `start_tick` and `end_tick` - Manual clip upload and in-page playback for `.mp4` and `.webm` +- Render worker scaffold with a `cs2-scripted` backend that resolves demos, creates per-attempt scripts/logs, and invokes a Windows wrapper script What does not exist yet: -- Automatic demo-to-video rendering - A separate render worker process -- A real CS2 capture backend - Camera automation - Encoding pipeline +What exists now for the first automation path: + +- Worker backend: `src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs` +- Wrapper script entrypoint: `src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1` +- Worker config: `src/Fragalytics.RenderWorker/appsettings.json` + +Current behavior of that path: + +- It resolves the source `.dem` from the clip job payload +- It creates per-attempt artifacts under `data/render-artifacts` +- It writes a launch script and log for each render attempt +- It invokes the configured wrapper script with resolved arguments +- It only marks success if the configured command produces the expected output file + +This means the next implementation work is focused in one place: + +- edit `render-cs2-clip.ps1` to launch CS2, drive demo playback, capture the clip, and write the output file + ## Important Constraint A `.dem` file is not video. It is replay data. To get a playable clip, the system must run Counter-Strike 2, load the demo, seek to the correct tick range, play the segment in-engine, capture the frames and audio, encode the output, and then attach the media file back to the clip job. diff --git a/src/Fragalytics.RenderWorker/Program.cs b/src/Fragalytics.RenderWorker/Program.cs index 38a99c3..62237ee 100644 --- a/src/Fragalytics.RenderWorker/Program.cs +++ b/src/Fragalytics.RenderWorker/Program.cs @@ -16,9 +16,19 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().Value; - return options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase) - ? new SampleCopyRenderBackend(serviceProvider.GetRequiredService>()) - : throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'."); + if (options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase)) + { + return new SampleCopyRenderBackend(serviceProvider.GetRequiredService>()); + } + + if (options.RenderWorker.Backend.Equals("cs2-scripted", StringComparison.OrdinalIgnoreCase)) + { + return new Cs2ScriptedRenderBackend( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>()); + } + + throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'."); }); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs new file mode 100644 index 0000000..c1e2938 --- /dev/null +++ b/src/Fragalytics.RenderWorker/Services/Cs2ScriptedRenderBackend.cs @@ -0,0 +1,300 @@ +using System.Diagnostics; +using System.Text; +using Fragalytics.Web; +using Fragalytics.Web.Infrastructure; +using Fragalytics.Web.Models; +using Microsoft.Extensions.Options; + +namespace Fragalytics.RenderWorker.Services; + +public sealed class Cs2ScriptedRenderBackend : IClipRenderBackend +{ + private readonly JsonAppStore _store; + private readonly FragalyticsOptions _options; + + public Cs2ScriptedRenderBackend(JsonAppStore store, IOptions options) + { + _store = store; + _options = options.Value; + } + + public string Name => "cs2-scripted"; + + public async Task RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken) + { + var renderJob = RenderJobContract.FromPayload(clipJob.Payload); + var demo = await ResolveDemoAsync(renderJob.DemoId, cancellationToken); + var attemptArtifacts = PrepareArtifacts(clipJob, renderJob, demo); + + await File.WriteAllTextAsync( + attemptArtifacts.ScriptPath, + BuildLaunchScript(attemptArtifacts.Command), + cancellationToken); + + if (string.IsNullOrWhiteSpace(_options.RenderWorker.Cs2.ExecutablePath)) + { + await WriteLogAsync( + attemptArtifacts.LogPath, + [ + "CS2 scripted backend is selected, but Fragalytics:RenderWorker:Cs2:ExecutablePath is not configured.", + $"Demo path: {demo.StoredPath}", + $"Output path: {attemptArtifacts.OutputPath}", + $"Script path: {attemptArtifacts.ScriptPath}" + ], + cancellationToken); + + throw new InvalidOperationException( + $"CS2 render backend is not configured. Review '{attemptArtifacts.ScriptPath}' and '{attemptArtifacts.LogPath}'."); + } + + if (!File.Exists(attemptArtifacts.Command.ExecutablePath)) + { + await WriteLogAsync( + attemptArtifacts.LogPath, + [ + $"Configured CS2 executable was not found: {attemptArtifacts.Command.ExecutablePath}", + $"Demo path: {demo.StoredPath}", + $"Script path: {attemptArtifacts.ScriptPath}" + ], + cancellationToken); + + throw new FileNotFoundException("Configured CS2 executable was not found.", attemptArtifacts.Command.ExecutablePath); + } + + await ExecuteCommandAsync(attemptArtifacts.Command, attemptArtifacts.LogPath, cancellationToken); + + if (!File.Exists(attemptArtifacts.OutputPath)) + { + throw new InvalidOperationException( + $"Configured render command completed without producing '{attemptArtifacts.OutputPath}'. Review '{attemptArtifacts.LogPath}'."); + } + + return new RenderedClipResult + { + FilePath = attemptArtifacts.OutputPath, + FileName = Path.GetFileName(attemptArtifacts.OutputPath), + ContentType = renderJob.GetSuggestedFileExtension().Equals(".webm", StringComparison.OrdinalIgnoreCase) + ? "video/webm" + : "video/mp4" + }; + } + + private async Task ResolveDemoAsync(int demoId, CancellationToken cancellationToken) + { + var state = await _store.GetSnapshotAsync(cancellationToken); + return state.Demos.FirstOrDefault(item => item.Id == demoId) + ?? throw new InvalidOperationException($"Render job references demo #{demoId}, but that demo was not found."); + } + + private AttemptArtifacts PrepareArtifacts(ClipJobRecord clipJob, RenderJobContract renderJob, DemoRecord demo) + { + var artifactsRoot = Path.GetFullPath(_options.RenderWorker.Cs2.ArtifactsDirectory); + var attemptDirectory = Path.Combine(artifactsRoot, $"clipjob_{clipJob.Id}", $"attempt_{Math.Max(1, clipJob.AttemptCount):000}"); + Directory.CreateDirectory(attemptDirectory); + + var outputFileName = $"clipjob_{clipJob.Id}_highlight_{renderJob.HighlightId}{renderJob.GetSuggestedFileExtension()}"; + var outputPath = Path.Combine(attemptDirectory, outputFileName); + var logPath = Path.Combine(attemptDirectory, "render.log"); + var scriptExtension = OperatingSystem.IsWindows() ? ".cmd" : ".sh"; + var scriptPath = Path.Combine(attemptDirectory, $"launch{scriptExtension}"); + + var wrapperScriptPath = ResolveWrapperScriptPath(); + return new AttemptArtifacts + { + OutputPath = outputPath, + LogPath = logPath, + ScriptPath = scriptPath, + Command = new RenderCommand + { + ExecutablePath = ResolveExecutablePath(), + WorkingDirectory = ResolveWorkingDirectory(), + Arguments = ExpandTemplate(ResolveArgumentsTemplate(), clipJob, renderJob, demo, outputPath, wrapperScriptPath, attemptDirectory) + } + }; + } + + private string ResolveExecutablePath() + { + if (!string.IsNullOrWhiteSpace(_options.RenderWorker.Cs2.ExecutablePath)) + { + return Path.GetFullPath(_options.RenderWorker.Cs2.ExecutablePath); + } + + return OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh"; + } + + private string ResolveArgumentsTemplate() + { + if (!string.IsNullOrWhiteSpace(_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}\""; + } + + private string ResolveWorkingDirectory() + { + if (!string.IsNullOrWhiteSpace(_options.RenderWorker.Cs2.WorkingDirectory)) + { + return Path.GetFullPath(_options.RenderWorker.Cs2.WorkingDirectory); + } + + return Environment.CurrentDirectory; + } + + private string ResolveWrapperScriptPath() + { + var configured = _options.RenderWorker.Cs2.WrapperScriptPath; + if (string.IsNullOrWhiteSpace(configured)) + { + return string.Empty; + } + + return Path.IsPathRooted(configured) + ? Path.GetFullPath(configured) + : Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, configured)); + } + + private static string ExpandTemplate( + string template, + ClipJobRecord clipJob, + RenderJobContract renderJob, + DemoRecord demo, + string outputPath, + string wrapperScriptPath, + string artifactsDirectory) + { + return template + .Replace("{clipJobId}", clipJob.Id.ToString(), StringComparison.Ordinal) + .Replace("{highlightId}", renderJob.HighlightId.ToString(), StringComparison.Ordinal) + .Replace("{demoId}", renderJob.DemoId.ToString(), StringComparison.Ordinal) + .Replace("{demoPath}", demo.StoredPath, StringComparison.Ordinal) + .Replace("{outputPath}", outputPath, StringComparison.Ordinal) + .Replace("{startTick}", renderJob.StartTick.ToString(), StringComparison.Ordinal) + .Replace("{endTick}", renderJob.EndTick.ToString(), StringComparison.Ordinal) + .Replace("{cameraMode}", renderJob.CameraMode, StringComparison.Ordinal) + .Replace("{resolution}", renderJob.Resolution, StringComparison.Ordinal) + .Replace("{fps}", renderJob.Fps, StringComparison.Ordinal) + .Replace("{wrapperScriptPath}", wrapperScriptPath, StringComparison.Ordinal) + .Replace("{artifactsDirectory}", artifactsDirectory, StringComparison.Ordinal); + } + + private static string BuildLaunchScript(RenderCommand command) + { + if (OperatingSystem.IsWindows()) + { + return $"@echo off{Environment.NewLine}\"{command.ExecutablePath}\" {command.Arguments}{Environment.NewLine}"; + } + + return $"#!/usr/bin/env bash{Environment.NewLine}set -euo pipefail{Environment.NewLine}\"{command.ExecutablePath}\" {command.Arguments}{Environment.NewLine}"; + } + + private async Task ExecuteCommandAsync(RenderCommand command, string logPath, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = command.ExecutablePath, + Arguments = command.Arguments, + WorkingDirectory = command.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, _options.RenderWorker.Cs2.TimeoutSeconds))); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + var timeoutOutput = await stdOutTask; + var timeoutError = await stdErrTask; + await WriteLogAsync( + logPath, + [ + $"Command timed out after {_options.RenderWorker.Cs2.TimeoutSeconds} seconds.", + $"Executable: {command.ExecutablePath}", + $"Arguments: {command.Arguments}", + "STDOUT:", + timeoutOutput, + "STDERR:", + timeoutError + ], + CancellationToken.None); + + throw new TimeoutException($"Render command timed out. Review '{logPath}'."); + } + + var stdOut = await stdOutTask; + var stdErr = await stdErrTask; + await WriteLogAsync( + logPath, + [ + $"Executable: {command.ExecutablePath}", + $"Arguments: {command.Arguments}", + $"WorkingDirectory: {command.WorkingDirectory}", + $"ExitCode: {process.ExitCode}", + "STDOUT:", + stdOut, + "STDERR:", + stdErr + ], + cancellationToken); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Render command failed with exit code {process.ExitCode}. Review '{logPath}'."); + } + } + + private static async Task WriteLogAsync(string logPath, IEnumerable lines, CancellationToken cancellationToken) + { + var builder = new StringBuilder(); + foreach (var line in lines) + { + builder.AppendLine(line); + } + + await File.WriteAllTextAsync(logPath, builder.ToString(), cancellationToken); + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + } + + private sealed class AttemptArtifacts + { + public string OutputPath { get; set; } = string.Empty; + public string LogPath { get; set; } = string.Empty; + public string ScriptPath { get; set; } = string.Empty; + public RenderCommand Command { get; set; } = new(); + } + + private sealed class RenderCommand + { + public string ExecutablePath { get; set; } = string.Empty; + public string Arguments { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; + } +} diff --git a/src/Fragalytics.RenderWorker/Services/RenderCoordinator.cs b/src/Fragalytics.RenderWorker/Services/RenderCoordinator.cs index 8a3dc06..1016d7b 100644 --- a/src/Fragalytics.RenderWorker/Services/RenderCoordinator.cs +++ b/src/Fragalytics.RenderWorker/Services/RenderCoordinator.cs @@ -2,6 +2,7 @@ using Fragalytics.Web; using Fragalytics.Web.Infrastructure; using Fragalytics.Web.Models; using Fragalytics.Web.Services; +using Fragalytics.Web.Services.Render; using Microsoft.Extensions.Options; namespace Fragalytics.RenderWorker.Services; @@ -27,10 +28,11 @@ public sealed class RenderCoordinator public async Task ProcessNextAsync(CancellationToken cancellationToken) { + var now = DateTime.UtcNow; var claimedJob = await _store.UpdateAsync(state => { var next = state.ClipJobs - .Where(job => string.Equals(job.Status, "pending", StringComparison.OrdinalIgnoreCase)) + .Where(job => ClipJobRetryPolicy.CanStartAttempt(job, now)) .OrderBy(job => job.CreatedAtUtc) .FirstOrDefault(); @@ -39,16 +41,14 @@ public sealed class RenderCoordinator return null; } - next.Status = "rendering"; - next.Renderer = _backend.Name; - next.ErrorMessage = null; - next.StartedAtUtc = DateTime.UtcNow; - next.Payload["backend"] = _backend.Name; + ClipJobRetryPolicy.MarkAttemptStarted(next, _backend.Name, now); + + var renderJob = RenderJobContract.FromPayload(next.Payload); return new ClaimedClipJob { ClipJobId = next.Id, - FileNameHint = $"highlight_{next.HighlightId}.mp4" + FileNameHint = $"highlight_{renderJob.HighlightId}{renderJob.GetSuggestedFileExtension()}" }; }, cancellationToken); @@ -83,6 +83,7 @@ public sealed class RenderCoordinator var job = stateUpdate.ClipJobs.First(item => item.Id == fullJob.Id); job.Renderer = _backend.Name; job.ErrorMessage = null; + job.RetryAfterUtc = null; job.CompletedAtUtc = DateTime.UtcNow; return 0; }, cancellationToken); @@ -94,10 +95,11 @@ public sealed class RenderCoordinator await _store.UpdateAsync(state => { var job = state.ClipJobs.First(item => item.Id == claimedJob.ClipJobId); - job.Status = "failed"; - job.ErrorMessage = exception.Message; - job.CompletedAtUtc = DateTime.UtcNow; - job.Payload["failure"] = exception.Message; + ClipJobRetryPolicy.MarkAttemptFailed( + job, + exception.Message, + DateTime.UtcNow, + TimeSpan.FromSeconds(Math.Max(0, _options.RenderWorker.RetryDelaySeconds))); return 0; }, cancellationToken); diff --git a/src/Fragalytics.RenderWorker/appsettings.json b/src/Fragalytics.RenderWorker/appsettings.json index aab23cd..e980e60 100644 --- a/src/Fragalytics.RenderWorker/appsettings.json +++ b/src/Fragalytics.RenderWorker/appsettings.json @@ -4,10 +4,20 @@ "UploadDirectory": "data/uploads", "RenderWorker": { "PollSeconds": 3, - "Backend": "sample-copy", + "Backend": "cs2-scripted", "SampleClipPath": "", "TempDirectory": "data/render-temp", - "RunContinuously": true + "RunContinuously": true, + "MaxAttempts": 2, + "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}\"", + "WorkingDirectory": "", + "TimeoutSeconds": 900, + "ArtifactsDirectory": "data/render-artifacts", + "WrapperScriptPath": "scripts/render-cs2-clip.ps1" + } } } } diff --git a/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 b/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 new file mode 100644 index 0000000..44cced6 --- /dev/null +++ b/src/Fragalytics.RenderWorker/scripts/render-cs2-clip.ps1 @@ -0,0 +1,100 @@ +param( + [Parameter(Mandatory = $true)] + [string]$DemoPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $true)] + [int]$StartTick, + + [Parameter(Mandatory = $true)] + [int]$EndTick, + + [Parameter(Mandatory = $true)] + [string]$CameraMode, + + [Parameter(Mandatory = $true)] + [string]$Resolution, + + [Parameter(Mandatory = $true)] + [int]$Fps, + + [Parameter(Mandatory = $false)] + [string]$ArtifactsDirectory = "", + + [Parameter(Mandatory = $false)] + [string]$Cs2Path = $env:FRAGALYTICS_CS2_PATH, + + [Parameter(Mandatory = $false)] + [string]$SteamPath = $env:FRAGALYTICS_STEAM_PATH, + + [Parameter(Mandatory = $false)] + [string]$ObsCliPath = $env:FRAGALYTICS_OBS_CLI_PATH +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $DemoPath)) { + throw "Demo file was not found: $DemoPath" +} + +$outputDirectory = Split-Path -Parent $OutputPath +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") +} +else { + Join-Path $ArtifactsDirectory "render-manifest.json" +} + +$manifest = [ordered]@{ + demoPath = $DemoPath + outputPath = $OutputPath + startTick = $StartTick + endTick = $EndTick + cameraMode = $CameraMode + resolution = $Resolution + fps = $Fps + cs2Path = $Cs2Path + steamPath = $SteamPath + obsCliPath = $ObsCliPath + createdAtUtc = [DateTime]::UtcNow.ToString("O") +} + +$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + +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." +} + +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. + +Edit: + $PSScriptRoot\render-cs2-clip.ps1 + +Inputs captured in: + $manifestPath + +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 +"@ diff --git a/src/Fragalytics.Web/FragalyticsOptions.cs b/src/Fragalytics.Web/FragalyticsOptions.cs index 57cf11c..81273cd 100644 --- a/src/Fragalytics.Web/FragalyticsOptions.cs +++ b/src/Fragalytics.Web/FragalyticsOptions.cs @@ -4,6 +4,7 @@ public sealed class FragalyticsOptions { public string DataDirectory { get; set; } = "data"; public string UploadDirectory { get; set; } = "data/uploads"; + public long UploadMaxBytes { get; set; } = 536_870_912; public int WorkerPollSeconds { get; set; } = 2; public bool AutoStartWorker { get; set; } = true; public ParserOptions Parser { get; set; } = new(); @@ -24,4 +25,17 @@ public sealed class RenderWorkerOptions public string? SampleClipPath { get; set; } public string TempDirectory { get; set; } = "data/render-temp"; public bool RunContinuously { get; set; } = true; + public int MaxAttempts { get; set; } = 2; + public int RetryDelaySeconds { get; set; } = 30; + public Cs2RenderOptions Cs2 { get; set; } = new(); +} + +public sealed class Cs2RenderOptions +{ + public string ExecutablePath { get; set; } = string.Empty; + public string Arguments { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 900; + public string ArtifactsDirectory { get; set; } = "data/render-artifacts"; + public string WrapperScriptPath { get; set; } = "scripts/render-cs2-clip.ps1"; } diff --git a/src/Fragalytics.Web/Infrastructure/AppPathResolver.cs b/src/Fragalytics.Web/Infrastructure/AppPathResolver.cs new file mode 100644 index 0000000..0186947 --- /dev/null +++ b/src/Fragalytics.Web/Infrastructure/AppPathResolver.cs @@ -0,0 +1,161 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace Fragalytics.Web.Infrastructure; + +public static partial class AppPathResolver +{ + public static string ResolveContentRoot(string currentDirectory, string appBaseDirectory) + { + foreach (var candidate in GetCandidateDirectories(currentDirectory, appBaseDirectory)) + { + if (IsAppRoot(candidate)) + { + return candidate; + } + } + + return currentDirectory; + } + + private static IEnumerable GetCandidateDirectories(string currentDirectory, string appBaseDirectory) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var seed in new[] { currentDirectory, appBaseDirectory }) + { + foreach (var variant in ExpandVariants(seed)) + { + foreach (var candidate in ExpandParents(variant)) + { + var normalized = Normalize(candidate); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + { + yield return normalized; + } + } + } + } + } + + private static IEnumerable ExpandVariants(string path) + { + var normalized = Normalize(path); + if (string.IsNullOrWhiteSpace(normalized)) + { + yield break; + } + + yield return normalized; + + var mixedPath = StripMixedMountPrefix(normalized); + if (!string.Equals(mixedPath, normalized, StringComparison.OrdinalIgnoreCase)) + { + yield return mixedPath; + } + + var translated = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? TryTranslateWslToWindowsPath(mixedPath) + : TryTranslateWindowsToWslPath(mixedPath); + + if (!string.IsNullOrWhiteSpace(translated)) + { + yield return translated; + } + } + + private static IEnumerable ExpandParents(string path) + { + var current = path; + while (!string.IsNullOrWhiteSpace(current)) + { + yield return current; + + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase)) + { + yield break; + } + + current = parent; + } + } + + private static bool IsAppRoot(string path) + { + return Directory.Exists(path) + && Directory.Exists(Path.Combine(path, "wwwroot")) + && File.Exists(Path.Combine(path, "scoring-rules.json")) + && File.Exists(Path.Combine(path, "Fragalytics.Web.csproj")); + } + + private static string Normalize(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var separatorsNormalized = path.Trim().Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + return separatorsNormalized.TrimEnd(Path.DirectorySeparatorChar); + } + + private static string StripMixedMountPrefix(string path) + { + var mixedPrefix = MixedMountPrefixRegex().Match(path); + if (!mixedPrefix.Success) + { + return path; + } + + var driveLetter = char.ToLowerInvariant(mixedPrefix.Groups["drive"].Value[0]); + var mountedDrive = char.ToLowerInvariant(mixedPrefix.Groups["mountedDrive"].Value[0]); + if (driveLetter != mountedDrive) + { + return path; + } + + return Path.DirectorySeparatorChar == '\\' + ? $"{char.ToUpperInvariant(driveLetter)}:{Path.DirectorySeparatorChar}{mixedPrefix.Groups["rest"].Value}" + : $"{Path.DirectorySeparatorChar}mnt{Path.DirectorySeparatorChar}{driveLetter}{Path.DirectorySeparatorChar}{mixedPrefix.Groups["rest"].Value}"; + } + + private static string? TryTranslateWslToWindowsPath(string path) + { + var match = WslPathRegex().Match(path); + if (!match.Success) + { + return null; + } + + var driveLetter = char.ToUpperInvariant(match.Groups["drive"].Value[0]); + var remainder = match.Groups["rest"].Value.Replace('/', '\\'); + return string.IsNullOrWhiteSpace(remainder) + ? $"{driveLetter}:\\" + : $"{driveLetter}:\\{remainder}"; + } + + private static string? TryTranslateWindowsToWslPath(string path) + { + var match = WindowsPathRegex().Match(path); + if (!match.Success) + { + return null; + } + + var driveLetter = char.ToLowerInvariant(match.Groups["drive"].Value[0]); + var remainder = match.Groups["rest"].Value.Replace('\\', '/'); + return string.IsNullOrWhiteSpace(remainder) + ? $"/mnt/{driveLetter}" + : $"/mnt/{driveLetter}/{remainder}"; + } + + [GeneratedRegex(@"^(?[A-Za-z]):[\\/]+mnt[\\/]+(?[A-Za-z])[\\/]+(?.+)$", RegexOptions.Compiled)] + private static partial Regex MixedMountPrefixRegex(); + + [GeneratedRegex(@"^/mnt/(?[A-Za-z])(?:/(?.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase)] + private static partial Regex WslPathRegex(); + + [GeneratedRegex(@"^(?[A-Za-z]):[\\/]*(?.*)$", RegexOptions.Compiled)] + private static partial Regex WindowsPathRegex(); +} diff --git a/src/Fragalytics.Web/Models/AppState.cs b/src/Fragalytics.Web/Models/AppState.cs index 8b1c484..6837809 100644 --- a/src/Fragalytics.Web/Models/AppState.cs +++ b/src/Fragalytics.Web/Models/AppState.cs @@ -124,11 +124,92 @@ public sealed class ClipJobRecord public Dictionary Payload { get; set; } = []; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public int? ClipAssetId { get; set; } + public int AttemptCount { get; set; } + public int MaxAttempts { get; set; } = 2; public string? ErrorMessage { get; set; } + public DateTime? LastAttemptedAtUtc { get; set; } + public DateTime? RetryAfterUtc { get; set; } public DateTime? StartedAtUtc { get; set; } public DateTime? CompletedAtUtc { get; set; } } +public sealed class RenderJobContract +{ + public const string CurrentVersion = "1"; + public const string DefaultCameraMode = "killer_pov"; + public const string DefaultOutputFormat = "mp4"; + public const string DefaultResolution = "1920x1080"; + public const string DefaultFps = "60"; + + public string Version { get; set; } = CurrentVersion; + public int DemoId { get; set; } + public int MatchId { get; set; } + public int HighlightId { get; set; } + public int SourceEventId { get; set; } + public int StartTick { get; set; } + public int EndTick { get; set; } + 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 Dictionary ToPayload() + { + return new Dictionary + { + ["version"] = Version, + ["demoId"] = DemoId.ToString(), + ["matchId"] = MatchId.ToString(), + ["highlightId"] = HighlightId.ToString(), + ["sourceEventId"] = SourceEventId.ToString(), + ["startTick"] = StartTick.ToString(), + ["endTick"] = EndTick.ToString(), + ["cameraMode"] = CameraMode, + ["outputFormat"] = OutputFormat, + ["resolution"] = Resolution, + ["fps"] = Fps + }; + } + + public static RenderJobContract FromPayload(IReadOnlyDictionary payload) + { + return new RenderJobContract + { + Version = ReadString(payload, "version", CurrentVersion), + DemoId = ReadInt(payload, "demoId"), + MatchId = ReadInt(payload, "matchId"), + HighlightId = ReadInt(payload, "highlightId"), + SourceEventId = ReadInt(payload, "sourceEventId"), + StartTick = ReadInt(payload, "startTick"), + EndTick = ReadInt(payload, "endTick"), + CameraMode = ReadString(payload, "cameraMode", DefaultCameraMode), + OutputFormat = ReadString(payload, "outputFormat", DefaultOutputFormat), + Resolution = ReadString(payload, "resolution", DefaultResolution), + Fps = ReadString(payload, "fps", DefaultFps) + }; + } + + public string GetSuggestedFileExtension() + { + if (string.Equals(OutputFormat, "webm", StringComparison.OrdinalIgnoreCase)) + { + return ".webm"; + } + + return ".mp4"; + } + + private static int ReadInt(IReadOnlyDictionary payload, string key) + { + return payload.TryGetValue(key, out var raw) && int.TryParse(raw, out var value) ? value : 0; + } + + private static string ReadString(IReadOnlyDictionary payload, string key, string fallback) + { + return payload.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + } +} + public sealed class ClipAssetRecord { public int Id { get; set; } diff --git a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml index 0a67655..ee9030c 100644 --- a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml +++ b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml @@ -8,11 +8,11 @@

Clip Jobs

You can manually attach a rendered `.mp4` or `.webm` file to any clip job. Once attached, the clip is playable in the page and on related highlight views.

- + @if (Model.ClipJobs.Count == 0) { - + } else { @@ -20,8 +20,16 @@ { - + +
IDStatusRendererTicksPlaybackUploadPayload
IDStatusRendererAttemptsTicksPlaybackUploadPayload
No clip jobs created yet.
No clip jobs created yet.
#@row.ClipJob.Id@row.ClipJob.Status + @row.ClipJob.Status + @if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage)) + { +
+ @row.ClipJob.ErrorMessage + } +
@row.ClipJob.Renderer@row.ClipJob.AttemptCount @row.ClipJob.StartTick - @row.ClipJob.EndTick @if (row.ClipAsset is null) @@ -37,6 +45,7 @@
+ @Html.AntiForgeryToken() diff --git a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml.cs b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml.cs index d8f38df..519ef3a 100644 --- a/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml.cs +++ b/src/Fragalytics.Web/Pages/ClipJobs/Index.cshtml.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; namespace Fragalytics.Web.Pages.ClipJobs; +[IgnoreAntiforgeryToken] public sealed class IndexModel : PageModel { private readonly QueryService _queryService; diff --git a/src/Fragalytics.Web/Pages/Demos/Details.cshtml b/src/Fragalytics.Web/Pages/Demos/Details.cshtml index 2522e41..d96a04e 100644 --- a/src/Fragalytics.Web/Pages/Demos/Details.cshtml +++ b/src/Fragalytics.Web/Pages/Demos/Details.cshtml @@ -16,7 +16,13 @@ else

Demo #@Model.ViewModel.Demo.Id

@Model.ViewModel.Demo.OriginalFileName

- @Model.ViewModel.Demo.Status +
+ @Model.ViewModel.Demo.Status + + @Html.AntiForgeryToken() + + +
Parser
@Model.ViewModel.Demo.ParserBackend
diff --git a/src/Fragalytics.Web/Pages/Demos/Details.cshtml.cs b/src/Fragalytics.Web/Pages/Demos/Details.cshtml.cs index ce5da79..fc19f8d 100644 --- a/src/Fragalytics.Web/Pages/Demos/Details.cshtml.cs +++ b/src/Fragalytics.Web/Pages/Demos/Details.cshtml.cs @@ -4,13 +4,16 @@ using Microsoft.AspNetCore.Mvc.RazorPages; namespace Fragalytics.Web.Pages.Demos; +[IgnoreAntiforgeryToken] public sealed class DetailsModel : PageModel { private readonly QueryService _queryService; + private readonly DemoIngestService _demoIngestService; - public DetailsModel(QueryService queryService) + public DetailsModel(QueryService queryService, DemoIngestService demoIngestService) { _queryService = queryService; + _demoIngestService = demoIngestService; } public DemoDetailsViewModel? ViewModel { get; private set; } @@ -20,4 +23,10 @@ public sealed class DetailsModel : PageModel ViewModel = await _queryService.GetDemoDetailsAsync(id, cancellationToken); return ViewModel is null ? NotFound() : Page(); } + + public async Task OnPostDeleteAsync(int id, CancellationToken cancellationToken) + { + await _demoIngestService.DeleteDemoAsync(id, cancellationToken); + return RedirectToPage("/Index", new { status = $"Deleted demo #{id}." }); + } } diff --git a/src/Fragalytics.Web/Pages/Index.cshtml b/src/Fragalytics.Web/Pages/Index.cshtml index f695353..5855598 100644 --- a/src/Fragalytics.Web/Pages/Index.cshtml +++ b/src/Fragalytics.Web/Pages/Index.cshtml @@ -10,11 +10,26 @@

Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.

- + @Html.AntiForgeryToken() +
+@if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) +{ +
+

@Model.StatusMessage

+
+} + +@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) +{ +
+

@Model.ErrorMessage

+
+} +

@Model.Dashboard.DemoCount

Demos

@Model.Dashboard.MatchCount

Matches

@@ -27,6 +42,7 @@

Recent Demos

+ @Html.AntiForgeryToken()
@@ -57,6 +73,7 @@

Recent Clip Jobs

+ @Html.AntiForgeryToken()
diff --git a/src/Fragalytics.Web/Pages/Index.cshtml.cs b/src/Fragalytics.Web/Pages/Index.cshtml.cs index d3813cf..cae2c2a 100644 --- a/src/Fragalytics.Web/Pages/Index.cshtml.cs +++ b/src/Fragalytics.Web/Pages/Index.cshtml.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; namespace Fragalytics.Web.Pages; +[IgnoreAntiforgeryToken] public sealed class IndexModel : PageModel { private readonly QueryService _queryService; @@ -15,26 +16,36 @@ public sealed class IndexModel : PageModel _demoIngestService = demoIngestService; } - [BindProperty] - public IFormFile? Upload { get; set; } - public DashboardViewModel Dashboard { get; private set; } = new(); - public async Task OnGetAsync(CancellationToken cancellationToken) + public string? StatusMessage { get; private set; } + public string? ErrorMessage { get; private set; } + + public async Task OnGetAsync(string? status, string? error, CancellationToken cancellationToken) { + StatusMessage = status; + ErrorMessage = error; Dashboard = await _queryService.GetDashboardAsync(cancellationToken); } public async Task OnPostUploadAsync(CancellationToken cancellationToken) { - if (Upload is null || string.IsNullOrWhiteSpace(Upload.FileName) || !Upload.FileName.EndsWith(".dem", StringComparison.OrdinalIgnoreCase)) + var form = await Request.ReadFormAsync(cancellationToken); + var upload = form.Files.GetFile("upload") ?? form.Files.FirstOrDefault(); + + if (upload is null || upload.Length == 0) { - return RedirectToPage(); + return RedirectToPage(new { error = "Select a .dem file to upload." }); } - await using var stream = Upload.OpenReadStream(); - var id = await _demoIngestService.SaveUploadAsync(Upload.FileName, stream, cancellationToken); - return RedirectToPage("/Demos/Details", new { id }); + if (string.IsNullOrWhiteSpace(upload.FileName) || !upload.FileName.EndsWith(".dem", StringComparison.OrdinalIgnoreCase)) + { + return RedirectToPage(new { error = "Only .dem files are supported." }); + } + + await using var stream = upload.OpenReadStream(); + var id = await _demoIngestService.SaveUploadAsync(upload.FileName, stream, cancellationToken); + return RedirectToPage(new { status = $"Uploaded demo #{id}: {Path.GetFileName(upload.FileName)}" }); } public async Task OnPostRunWorkerAsync(CancellationToken cancellationToken) diff --git a/src/Fragalytics.Web/Program.cs b/src/Fragalytics.Web/Program.cs index a45a3ac..ca38aeb 100644 --- a/src/Fragalytics.Web/Program.cs +++ b/src/Fragalytics.Web/Program.cs @@ -4,11 +4,37 @@ using Fragalytics.Web.Services; using Fragalytics.Web.Services.Parser; using Fragalytics.Web.Services.Render; using Fragalytics.Web.Services.Scoring; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Options; -var builder = WebApplication.CreateBuilder(args); +var contentRoot = AppPathResolver.ResolveContentRoot(Directory.GetCurrentDirectory(), AppContext.BaseDirectory); +var webRoot = Path.Combine(contentRoot, "wwwroot"); + +var builder = WebApplication.CreateBuilder(new WebApplicationOptions +{ + Args = args, + ContentRootPath = contentRoot, + WebRootPath = Directory.Exists(webRoot) ? webRoot : null +}); builder.Services.Configure(builder.Configuration.GetSection("Fragalytics")); +var uploadMaxBytes = builder.Configuration.GetValue("Fragalytics:UploadMaxBytes") ?? 536_870_912; +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxRequestBodySize = uploadMaxBytes; +}); +builder.Services.Configure(options => +{ + options.MultipartBodyLengthLimit = uploadMaxBytes; +}); +var dataProtectionRoot = Path.GetFullPath(Path.Combine(contentRoot, builder.Configuration["Fragalytics:DataDirectory"] ?? "data", "keys")); +Directory.CreateDirectory(dataProtectionRoot); + +builder.Services + .AddDataProtection() + .SetApplicationName("Fragalytics.Web") + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionRoot)); builder.Services.AddRazorPages(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Fragalytics.Web/Services/ClipMediaService.cs b/src/Fragalytics.Web/Services/ClipMediaService.cs index 28a4e0c..57eb6c0 100644 --- a/src/Fragalytics.Web/Services/ClipMediaService.cs +++ b/src/Fragalytics.Web/Services/ClipMediaService.cs @@ -78,7 +78,6 @@ public sealed class ClipMediaService clipJob.Status = "rendered"; clipJob.ErrorMessage = null; clipJob.CompletedAtUtc = DateTime.UtcNow; - clipJob.Payload["playback"] = "available"; return asset.Id; }, cancellationToken); } diff --git a/src/Fragalytics.Web/Services/DemoIngestService.cs b/src/Fragalytics.Web/Services/DemoIngestService.cs index 97dee5d..f121443 100644 --- a/src/Fragalytics.Web/Services/DemoIngestService.cs +++ b/src/Fragalytics.Web/Services/DemoIngestService.cs @@ -207,6 +207,28 @@ public sealed class DemoIngestService }, cancellationToken); } + public async Task DeleteDemoAsync(int demoId, CancellationToken cancellationToken) + { + return await _store.UpdateAsync(state => + { + var demo = state.Demos.FirstOrDefault(item => item.Id == demoId); + if (demo is null) + { + return false; + } + + DeleteExistingMatchGraph(state, demo.MatchId); + + if (!string.IsNullOrWhiteSpace(demo.StoredPath) && File.Exists(demo.StoredPath)) + { + File.Delete(demo.StoredPath); + } + + state.Demos.RemoveAll(item => item.Id == demoId); + return true; + }, cancellationToken); + } + private static void DeleteExistingMatchGraph(AppState state, int? matchId) { if (matchId is null) @@ -218,6 +240,20 @@ public sealed class DemoIngestService var clipAssetIds = state.ClipJobs.Where(item => highlightIds.Contains(item.HighlightId) && item.ClipAssetId.HasValue) .Select(item => item.ClipAssetId!.Value) .ToHashSet(); + var clipAssetPaths = state.ClipAssets + .Where(item => clipAssetIds.Contains(item.Id)) + .Select(item => item.StoredPath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToList(); + + foreach (var clipAssetPath in clipAssetPaths) + { + if (File.Exists(clipAssetPath)) + { + File.Delete(clipAssetPath); + } + } + state.ClipJobs.RemoveAll(item => highlightIds.Contains(item.HighlightId)); state.ClipAssets.RemoveAll(item => clipAssetIds.Contains(item.Id)); state.Highlights.RemoveAll(item => item.MatchId == matchId); diff --git a/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs b/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs new file mode 100644 index 0000000..38aa02c --- /dev/null +++ b/src/Fragalytics.Web/Services/Render/ClipJobRetryPolicy.cs @@ -0,0 +1,38 @@ +using Fragalytics.Web.Models; + +namespace Fragalytics.Web.Services.Render; + +public static class ClipJobRetryPolicy +{ + public static int GetEffectiveMaxAttempts(ClipJobRecord job) + { + return Math.Max(1, job.MaxAttempts); + } + + public static bool CanStartAttempt(ClipJobRecord job, DateTime utcNow) + { + return string.Equals(job.Status, "pending", StringComparison.OrdinalIgnoreCase) + && (!job.RetryAfterUtc.HasValue || job.RetryAfterUtc <= utcNow) + && job.AttemptCount < GetEffectiveMaxAttempts(job); + } + + public static void MarkAttemptStarted(ClipJobRecord job, string renderer, DateTime utcNow) + { + job.Status = "rendering"; + job.Renderer = renderer; + job.AttemptCount++; + job.ErrorMessage = null; + job.LastAttemptedAtUtc = utcNow; + job.RetryAfterUtc = null; + job.StartedAtUtc = utcNow; + } + + public static void MarkAttemptFailed(ClipJobRecord job, string errorMessage, DateTime utcNow, TimeSpan retryDelay) + { + var hasAttemptsRemaining = job.AttemptCount < GetEffectiveMaxAttempts(job); + job.Status = hasAttemptsRemaining ? "pending" : "failed"; + job.ErrorMessage = errorMessage; + job.CompletedAtUtc = utcNow; + job.RetryAfterUtc = hasAttemptsRemaining ? utcNow.Add(retryDelay) : null; + } +} diff --git a/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs b/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs index 995932c..45c9277 100644 --- a/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs +++ b/src/Fragalytics.Web/Services/Render/NoOpRenderWorkerAdapter.cs @@ -8,6 +8,5 @@ public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter public void Enqueue(ClipJobRecord clipJob) { - clipJob.Payload["note"] = "MVP placeholder. Rendering is not implemented yet."; } } diff --git a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs index 14e25a0..f22ee1f 100644 --- a/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs +++ b/src/Fragalytics.Web/Services/Scoring/MatchScoringService.cs @@ -1,5 +1,6 @@ using Fragalytics.Web.Models; using Fragalytics.Web.Services.Render; +using Microsoft.Extensions.Options; namespace Fragalytics.Web.Services.Scoring; @@ -7,11 +8,16 @@ public sealed class MatchScoringService { private readonly ScoringRulesProvider _rulesProvider; private readonly IRenderWorkerAdapter _renderWorker; + private readonly FragalyticsOptions _options; - public MatchScoringService(ScoringRulesProvider rulesProvider, IRenderWorkerAdapter renderWorker) + public MatchScoringService( + ScoringRulesProvider rulesProvider, + IRenderWorkerAdapter renderWorker, + IOptions options) { _rulesProvider = rulesProvider; _renderWorker = renderWorker; + _options = options.Value; } public int RescoreMatch(AppState state, MatchRecord match) @@ -93,6 +99,16 @@ public sealed class MatchScoringService state.Highlights.Add(highlight); + var renderJob = new RenderJobContract + { + DemoId = match.DemoId, + MatchId = match.Id, + HighlightId = highlight.Id, + SourceEventId = candidate.Id, + StartTick = highlight.StartTick, + EndTick = highlight.EndTick + }; + var clipJob = new ClipJobRecord { Id = state.NextClipJobId++, @@ -101,18 +117,8 @@ public sealed class MatchScoringService Status = "pending", StartTick = highlight.StartTick, EndTick = highlight.EndTick, - Payload = new Dictionary - { - ["sourceEventId"] = candidate.Id.ToString(), - ["matchId"] = match.Id.ToString(), - ["highlightId"] = highlight.Id.ToString(), - ["startTick"] = highlight.StartTick.ToString(), - ["endTick"] = highlight.EndTick.ToString(), - ["cameraMode"] = "killer_pov", - ["outputFormat"] = "mp4", - ["resolution"] = "1920x1080", - ["fps"] = "60" - } + MaxAttempts = Math.Max(1, _options.RenderWorker.MaxAttempts), + Payload = renderJob.ToPayload() }; _renderWorker.Enqueue(clipJob); state.ClipJobs.Add(clipJob); diff --git a/src/Fragalytics.Web/appsettings.json b/src/Fragalytics.Web/appsettings.json index a7e88ca..a93d807 100644 --- a/src/Fragalytics.Web/appsettings.json +++ b/src/Fragalytics.Web/appsettings.json @@ -9,6 +9,7 @@ "Fragalytics": { "DataDirectory": "data", "UploadDirectory": "data/uploads", + "UploadMaxBytes": 536870912, "WorkerPollSeconds": 2, "AutoStartWorker": true, "Parser": { @@ -21,7 +22,16 @@ "Backend": "sample-copy", "SampleClipPath": "", "TempDirectory": "data/render-temp", - "RunContinuously": true + "RunContinuously": true, + "MaxAttempts": 2, + "RetryDelaySeconds": 30, + "Cs2": { + "ExecutablePath": "", + "Arguments": "", + "WorkingDirectory": "", + "TimeoutSeconds": 900, + "ArtifactsDirectory": "data/render-artifacts" + } } } } diff --git a/tests/Fragalytics.Web.Tests/Program.cs b/tests/Fragalytics.Web.Tests/Program.cs index 64b8523..b936750 100644 --- a/tests/Fragalytics.Web.Tests/Program.cs +++ b/tests/Fragalytics.Web.Tests/Program.cs @@ -29,6 +29,24 @@ catch (Exception exception) failures.Add($"Flow test failed: {exception.Message}"); } +try +{ + RunRetryFlowTest(); +} +catch (Exception exception) +{ + failures.Add($"Retry flow test failed: {exception.Message}"); +} + +try +{ + RunPathResolutionTest(); +} +catch (Exception exception) +{ + failures.Add($"Path resolution test failed: {exception.Message}"); +} + if (failures.Count > 0) { foreach (var failure in failures) @@ -111,11 +129,16 @@ static async Task RunFlowTestAsync() { DataDirectory = Path.Combine(root, "data"), UploadDirectory = Path.Combine(root, "uploads"), - AutoStartWorker = false + AutoStartWorker = false, + RenderWorker = new RenderWorkerOptions + { + MaxAttempts = 2, + RetryDelaySeconds = 30 + } }); - var scoringRulesSource = Path.GetFullPath( - Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Fragalytics.Web", "scoring-rules.json")); + var repoRoot = FindRepoRoot(); + var scoringRulesSource = Path.Combine(repoRoot, "src", "Fragalytics.Web", "scoring-rules.json"); File.Copy( scoringRulesSource, Path.Combine(root, "scoring-rules.json"), @@ -124,7 +147,7 @@ static async Task RunFlowTestAsync() var store = new JsonAppStore(options); var scoringProvider = new ScoringRulesProvider(environment); var renderWorker = new NoOpRenderWorkerAdapter(); - var scoring = new MatchScoringService(scoringProvider, renderWorker); + var scoring = new MatchScoringService(scoringProvider, renderWorker, options); var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring); var clipMedia = new ClipMediaService(store, options); @@ -140,17 +163,121 @@ static async Task RunFlowTestAsync() Assert(state.Matches.Count == 1, "Expected one match."); Assert(state.Highlights.Count > 0, "Expected highlights."); Assert(state.ClipJobs.Count == state.Highlights.Count, "Expected one clip job per highlight."); + Assert(state.ClipJobs[0].AttemptCount == 0, "Expected new clip job to have no worker attempts yet."); await using var clipPayload = new MemoryStream("fake rendered mp4 bytes"u8.ToArray()); await clipMedia.AttachClipAssetAsync(state.ClipJobs[0].Id, "sample.mp4", "video/mp4", clipPayload, CancellationToken.None); state = await store.GetSnapshotAsync(); Assert(state.ClipAssets.Count == 1, "Expected one clip asset."); Assert(state.ClipJobs[0].Status == "rendered", "Expected rendered clip status after attach."); + 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."); + + 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."); await ingest.RescoreAllAsync(CancellationToken.None); state = await store.GetSnapshotAsync(); Assert(state.Highlights.Count > 0, "Expected highlights after rescoring."); + + var deleted = await ingest.DeleteDemoAsync(demoId, CancellationToken.None); + Assert(deleted, "Expected demo deletion to succeed."); + state = await store.GetSnapshotAsync(); + Assert(state.Demos.Count == 0, "Expected demo to be removed after deletion."); + Assert(state.Matches.Count == 0, "Expected match graph to be removed after demo deletion."); + Assert(state.Highlights.Count == 0, "Expected highlights to be removed after demo deletion."); + Assert(state.ClipJobs.Count == 0, "Expected clip jobs to be removed after demo deletion."); +} + +static void RunRetryFlowTest() +{ + var firstJob = new ClipJobRecord + { + Id = 1, + HighlightId = 7, + Renderer = "future-renderer", + Status = "pending", + StartTick = 100, + EndTick = 200, + MaxAttempts = 2 + }; + + Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected pending job to be claimable before any attempts."); + ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow); + ClipJobRetryPolicy.MarkAttemptFailed(firstJob, "Simulated backend failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30)); + + Assert(firstJob.Status == "pending", "Expected failed render with retries remaining to return to pending."); + Assert(firstJob.AttemptCount == 1, "Expected first failed attempt to increment attempt count."); + Assert(firstJob.RetryAfterUtc.HasValue, "Expected retry to be scheduled after a failure."); + Assert(!string.IsNullOrWhiteSpace(firstJob.ErrorMessage), "Expected failure message to be preserved for retry visibility."); + Assert(!ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected retry window to block immediate reprocessing."); + + firstJob.RetryAfterUtc = DateTime.UtcNow.AddSeconds(-1); + Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected job to become claimable once retry delay passes."); + + ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow); + firstJob.Status = "rendered"; + firstJob.ErrorMessage = null; + firstJob.RetryAfterUtc = null; + firstJob.CompletedAtUtc = DateTime.UtcNow; + + Assert(firstJob.Status == "rendered", "Expected retry success to render the clip."); + Assert(firstJob.AttemptCount == 2, "Expected successful retry to record the second attempt."); + Assert(firstJob.RetryAfterUtc is null, "Expected successful retry to clear the retry schedule."); + + var terminalJob = new ClipJobRecord + { + Id = 2, + HighlightId = 8, + Status = "pending", + MaxAttempts = 1 + }; + + ClipJobRetryPolicy.MarkAttemptStarted(terminalJob, "sample-copy", DateTime.UtcNow); + ClipJobRetryPolicy.MarkAttemptFailed(terminalJob, "Permanent failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30)); + Assert(terminalJob.Status == "failed", "Expected job with no retries remaining to become failed."); + Assert(terminalJob.RetryAfterUtc is null, "Expected terminal failure to avoid scheduling another retry."); +} + +static void RunPathResolutionTest() +{ + var root = Path.Combine(FindRepoRoot(), "src", "Fragalytics.Web"); + + if (root.StartsWith("/mnt/", StringComparison.OrdinalIgnoreCase)) + { + var mixedWindowsPath = @"Z:\mnt\z\" + Path.GetRelativePath("/mnt/z", root).Replace('/', '\\'); + var resolvedFromMixedWindows = AppPathResolver.ResolveContentRoot(mixedWindowsPath, root); + Assert(resolvedFromMixedWindows == root, "Expected mixed Windows path to resolve to the real app root."); + } + + var resolvedFromBaseDirectory = AppPathResolver.ResolveContentRoot(Path.Combine(root, "bin", "Debug", "net8.0"), Path.Combine(root, "bin", "Debug", "net8.0")); + Assert(resolvedFromBaseDirectory == root, "Expected app base directory traversal to find the project root."); +} + +static string FindRepoRoot() +{ + var current = AppContext.BaseDirectory; + while (!string.IsNullOrWhiteSpace(current)) + { + if (File.Exists(Path.Combine(current, "Fragalytics.sln")) + && File.Exists(Path.Combine(current, "Directory.Build.props"))) + { + return current; + } + + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase)) + { + break; + } + + current = parent; + } + + throw new InvalidOperationException("Could not locate repository root from test base directory."); } static void Assert(bool condition, string message)