Adding features
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
"@
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user