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 -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"
}
}
}
}