Adding features

This commit is contained in:
hz
2026-04-21 08:10:51 -05:00
parent f90c6d7b50
commit c52bd0c50c
25 changed files with 1059 additions and 54 deletions
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<HostBuildFlavor Condition="'$(OS)' == 'Windows_NT'">windows</HostBuildFlavor>
<HostBuildFlavor Condition="'$(HostBuildFlavor)' == ''">unix</HostBuildFlavor>
</PropertyGroup>
<PropertyGroup>
<RepoArtifactsPath Condition="'$(RepoArtifactsPath)' == ''">$(MSBuildThisFileDirectory).artifacts/</RepoArtifactsPath>
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)' == ''">$(RepoArtifactsPath)obj/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseIntermediateOutputPath>
<BaseOutputPath Condition="'$(BaseOutputPath)' == ''">$(RepoArtifactsPath)bin/$(MSBuildProjectName)/$(HostBuildFlavor)/</BaseOutputPath>
<DefaultItemExcludes>$(DefaultItemExcludes);$(RepoArtifactsPath.Replace('\', '/'))**</DefaultItemExcludes>
</PropertyGroup>
</Project>
+3
View File
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<fallbackPackageFolders>
<clear />
</fallbackPackageFolders>
<packageSources>
<clear />
</packageSources>
+19 -2
View File
@@ -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.
+13 -3
View File
@@ -16,9 +16,19 @@ builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
return options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase)
? new SampleCopyRenderBackend(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>())
: throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'.");
if (options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase))
{
return new SampleCopyRenderBackend(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>());
}
if (options.RenderWorker.Backend.Equals("cs2-scripted", StringComparison.OrdinalIgnoreCase))
{
return new Cs2ScriptedRenderBackend(
serviceProvider.GetRequiredService<JsonAppStore>(),
serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>());
}
throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'.");
});
builder.Services.AddSingleton<RenderCoordinator>();
builder.Services.AddHostedService<RenderWorkerHostedService>();
@@ -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<FragalyticsOptions> options)
{
_store = store;
_options = options.Value;
}
public string Name => "cs2-scripted";
public async Task<RenderedClipResult> 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<DemoRecord> 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<string> 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;
}
}
@@ -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<int> 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);
+12 -2
View File
@@ -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"
}
}
}
}
@@ -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
"@
+14
View File
@@ -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";
}
@@ -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<string> GetCandidateDirectories(string currentDirectory, string appBaseDirectory)
{
var seen = new HashSet<string>(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<string> 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<string> 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(@"^(?<drive>[A-Za-z]):[\\/]+mnt[\\/]+(?<mountedDrive>[A-Za-z])[\\/]+(?<rest>.+)$", RegexOptions.Compiled)]
private static partial Regex MixedMountPrefixRegex();
[GeneratedRegex(@"^/mnt/(?<drive>[A-Za-z])(?:/(?<rest>.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex WslPathRegex();
[GeneratedRegex(@"^(?<drive>[A-Za-z]):[\\/]*(?<rest>.*)$", RegexOptions.Compiled)]
private static partial Regex WindowsPathRegex();
}
+81
View File
@@ -124,11 +124,92 @@ public sealed class ClipJobRecord
public Dictionary<string, string> 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<string, string> ToPayload()
{
return new Dictionary<string, string>
{
["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<string, string> 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<string, string> payload, string key)
{
return payload.TryGetValue(key, out var raw) && int.TryParse(raw, out var value) ? value : 0;
}
private static string ReadString(IReadOnlyDictionary<string, string> 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; }
@@ -8,11 +8,11 @@
<h1>Clip Jobs</h1>
<p class="muted">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.</p>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Renderer</th><th>Ticks</th><th>Playback</th><th>Upload</th><th>Payload</th></tr></thead>
<thead><tr><th>ID</th><th>Status</th><th>Renderer</th><th>Attempts</th><th>Ticks</th><th>Playback</th><th>Upload</th><th>Payload</th></tr></thead>
<tbody>
@if (Model.ClipJobs.Count == 0)
{
<tr><td colspan="7" class="muted">No clip jobs created yet.</td></tr>
<tr><td colspan="8" class="muted">No clip jobs created yet.</td></tr>
}
else
{
@@ -20,8 +20,16 @@
{
<tr>
<td>#@row.ClipJob.Id</td>
<td><span class="pill [email protected]">@row.ClipJob.Status</span></td>
<td>
<span class="pill [email protected]">@row.ClipJob.Status</span>
@if (!string.IsNullOrWhiteSpace(row.ClipJob.ErrorMessage))
{
<br />
<span class="muted">@row.ClipJob.ErrorMessage</span>
}
</td>
<td>@row.ClipJob.Renderer</td>
<td>@row.ClipJob.AttemptCount</td>
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
<td>
@if (row.ClipAsset is null)
@@ -37,6 +45,7 @@
</td>
<td>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
@Html.AntiForgeryToken()
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
<button type="submit" class="secondary">Attach Clip</button>
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.ClipJobs;
[IgnoreAntiforgeryToken]
public sealed class IndexModel : PageModel
{
private readonly QueryService _queryService;
@@ -16,7 +16,13 @@ else
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
</div>
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
<div>
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
<form method="post" asp-page-handler="Delete" asp-route-id="@Model.ViewModel.Demo.Id" style="margin-top: 0.75rem;">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Delete Demo</button>
</form>
</div>
</div>
<dl class="meta-grid">
<div><dt>Parser</dt><dd>@Model.ViewModel.Demo.ParserBackend</dd></div>
@@ -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<IActionResult> OnPostDeleteAsync(int id, CancellationToken cancellationToken)
{
await _demoIngestService.DeleteDemoAsync(id, cancellationToken);
return RedirectToPage("/Index", new { status = $"Deleted demo #{id}." });
}
}
+18 -1
View File
@@ -10,11 +10,26 @@
<p class="muted">Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.</p>
</div>
<form class="upload-form" method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<input type="file" asp-for="Upload" accept=".dem" required />
@Html.AntiForgeryToken()
<input type="file" name="upload" accept=".dem" required />
<button type="submit">Upload Demo</button>
</form>
</section>
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
{
<section class="card">
<p>@Model.StatusMessage</p>
</section>
}
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<section class="card">
<p class="muted">@Model.ErrorMessage</p>
</section>
}
<section class="stats-grid">
<article class="card"><h2>@Model.Dashboard.DemoCount</h2><p>Demos</p></article>
<article class="card"><h2>@Model.Dashboard.MatchCount</h2><p>Matches</p></article>
@@ -27,6 +42,7 @@
<div class="section-head">
<h2>Recent Demos</h2>
<form method="post" asp-page-handler="RunWorker">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Run Worker Once</button>
</form>
</div>
@@ -57,6 +73,7 @@
<div class="section-head">
<h2>Recent Clip Jobs</h2>
<form method="post" asp-page-handler="Rescore">
@Html.AntiForgeryToken()
<button type="submit" class="secondary">Rescore All Matches</button>
</form>
</div>
+20 -9
View File
@@ -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<IActionResult> 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<IActionResult> OnPostRunWorkerAsync(CancellationToken cancellationToken)
+27 -1
View File
@@ -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<FragalyticsOptions>(builder.Configuration.GetSection("Fragalytics"));
var uploadMaxBytes = builder.Configuration.GetValue<long?>("Fragalytics:UploadMaxBytes") ?? 536_870_912;
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = uploadMaxBytes;
});
builder.Services.Configure<FormOptions>(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<JsonAppStore>();
builder.Services.AddSingleton<ScoringRulesProvider>();
@@ -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);
}
@@ -207,6 +207,28 @@ public sealed class DemoIngestService
}, cancellationToken);
}
public async Task<bool> 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);
@@ -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;
}
}
@@ -8,6 +8,5 @@ public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter
public void Enqueue(ClipJobRecord clipJob)
{
clipJob.Payload["note"] = "MVP placeholder. Rendering is not implemented yet.";
}
}
@@ -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<FragalyticsOptions> 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<string, string>
{
["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);
+11 -1
View File
@@ -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"
}
}
}
}
+131 -4
View File
@@ -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)